From 621d71a451e4e8a24f60b9be7118817d0db316ca Mon Sep 17 00:00:00 2001 From: Adam Poulemanos Date: Fri, 11 Sep 2026 08:23:22 -0400 Subject: [PATCH 01/12] feat: implement licet improvement plan (tasks 1-12, F01-F19) Audit-driven overhaul from docs/2026-09-10-licet-audit.md per docs/superpowers/2026-09-10-licet-improvements.md: - Engine: git-aware selection (staged/changed/files) via git CLI, stateless scans (drop walk cache, sha2/gix deps), rule precedence with conflict surfacing, OOB REUSE.toml/dep5 detection, drift classification, snippet-aware lint with documented warning reasons - CLI: init preserves observed licensing (exact-path rules, safe overwrite semantics), check/apply/enforce selection flags, add-license offline bundle installs, completions gated on success - Contracts: report v2 schema with hint/warning duality, config schema, CLI exit-code contract; spec + data-model updated alongside - Tests: 390 passing across 19 suites, incl. 24 differential tests against reuse 6.2.0 (comparator-enforced) and perf/soak gates - CI: fmt, clippy -D warnings, test matrix, doctests, MSRV 1.89, release-cycle EPIPE probe, offline dependency guard --- .github/workflows/ci.yml | 59 +- CLAUDE.md | 4 +- Cargo.lock | 1430 +------------ Cargo.toml | 7 +- README.md | 30 +- benches/scan.rs | 179 +- build.rs | 3 + docs/2026-09-10-licet-audit.md | 247 +++ .../2026-09-10-licet-improvements.md | 504 +++++ .../contracts/cli.md | 181 +- .../contracts/config-schema.md | 25 +- .../contracts/report.schema.json | 68 +- .../data-model.md | 88 +- specs/001-declarative-license-headers/spec.md | 20 +- src/cli/add_license.rs | 175 +- src/cli/apply.rs | 783 +++++-- src/cli/check.rs | 308 ++- src/cli/init.rs | 601 +++++- src/cli/lint.rs | 226 +- src/cli/mod.rs | 88 +- src/comment/mod.rs | 99 +- src/config/mod.rs | 231 +- src/detect/mod.rs | 398 ++-- src/domain.rs | 345 ++- src/engine.rs | 289 ++- src/error.rs | 27 + src/exec.rs | 162 ++ src/lib.rs | 1 + src/reconcile/insert.rs | 139 +- src/reconcile/mod.rs | 821 ++++++- src/report/classify.rs | 332 ++- src/report/mod.rs | 215 +- src/report/render.rs | 126 +- src/reuse/atomic.rs | 684 ++++++ src/reuse/inventory.rs | 754 ++++++- src/reuse/mod.rs | 45 +- src/reuse/oob.rs | 1888 ++++++++++++++--- src/rules/mod.rs | 67 +- src/spdx/mod.rs | 469 ++-- src/walk/cache.rs | 180 -- src/walk/git.rs | 697 ++++++ src/walk/mod.rs | 753 +++++-- tests/common/mod.rs | 52 +- tests/determinism.rs | 60 +- tests/fixtures/table/sample.js | 6 + tests/fixtures/table/sample.jsx | 6 + tests/fixtures/table/sample.py | 5 + tests/fixtures/table/sample.rb | 6 + tests/fixtures/table/sample.sh | 5 + tests/perf.rs | 46 +- tests/report_contract.rs | 160 ++ tests/reuse_differential.rs | 784 +++++++ tests/safety.rs | 69 +- ...us1_snapshots__compliant_report_human.snap | 2 +- .../us1_snapshots__drift_report_human.snap | 2 +- tests/us1_check_drift.rs | 109 +- tests/us1_snapshots.rs | 3 + tests/us2_apply.rs | 493 ++++- tests/us2_partial.rs | 4 +- tests/us3_comment_style.rs | 77 + tests/us3_detect.rs | 238 +++ tests/us4_enforce.rs | 974 ++++++++- tests/us5_add_license.rs | 306 ++- tests/us5_reuse.rs | 1018 +++++++++ tests/us5_sidecars.rs | 152 +- tests/us5_snippets.rs | 67 + 66 files changed, 15110 insertions(+), 3282 deletions(-) create mode 100644 docs/2026-09-10-licet-audit.md create mode 100644 docs/superpowers/2026-09-10-licet-improvements.md create mode 100644 src/exec.rs create mode 100644 src/reuse/atomic.rs delete mode 100644 src/walk/cache.rs create mode 100644 src/walk/git.rs create mode 100644 tests/fixtures/table/sample.js create mode 100644 tests/fixtures/table/sample.jsx create mode 100644 tests/fixtures/table/sample.py create mode 100644 tests/fixtures/table/sample.rb create mode 100644 tests/fixtures/table/sample.sh create mode 100644 tests/report_contract.rs create mode 100644 tests/reuse_differential.rs create mode 100644 tests/us3_detect.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b09bbfc..5cbcb31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: toolchain: stable - uses: Swatinem/rust-cache@baf1a810e98b6a3001d0d7234864ed75a17c42fb - run: cargo fmt --all --check - - run: cargo clippy --all-targets -- -D warnings + - run: cargo clippy --locked --all-targets -- -D warnings test: name: test (${{ matrix.os }}) @@ -41,9 +41,55 @@ jobs: with: toolchain: stable - uses: Swatinem/rust-cache@baf1a810e98b6a3001d0d7234864ed75a17c42fb - - run: cargo test --all-targets + # Correctness suites only; the criterion harness runs in its own job. + - run: cargo test --locked --lib --bins --tests # Snapshot tests use `insta::assert_snapshot!`; doctests run separately. - - run: cargo test --doc + - run: cargo test --locked --doc + + reuse-differential: + name: reuse differential (6.2.0) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@b9e0990d219a03df7633c93f6f005a8fecbcab22 + - uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537 + with: + toolchain: stable + - uses: Swatinem/rust-cache@baf1a810e98b6a3001d0d7234864ed75a17c42fb + - name: Install pinned comparator in an isolated venv + run: | + python3 -m venv .venv-reuse + .venv-reuse/bin/python -m pip install 'reuse[charset-normalizer]==6.2.0' + echo "$PWD/.venv-reuse/bin" >> "$GITHUB_PATH" + # The comparator is mandatory here: absence or failure is fatal, and the + # REUSE-interop suites run against it (not skipped). + - name: Differential conformance (comparator required) + env: + LICET_REQUIRE_REUSE: "1" + run: cargo test --locked --test reuse_differential --test us5_reuse + - name: Publish comparator evidence + if: always() + run: | + { + echo "### Comparator versions" + echo '```' + .venv-reuse/bin/reuse --version 2>&1 | head -3 + echo '```' + echo "### Frozen comparator requirements" + echo '```' + .venv-reuse/bin/python -m pip freeze + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + bench: + name: criterion (scan) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@b9e0990d219a03df7633c93f6f005a8fecbcab22 + - uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537 + with: + toolchain: stable + - uses: Swatinem/rust-cache@baf1a810e98b6a3001d0d7234864ed75a17c42fb + - run: cargo bench --locked --bench scan -- --quick msrv: name: msrv (1.89) @@ -54,7 +100,7 @@ jobs: with: toolchain: 1.89.0 - uses: Swatinem/rust-cache@baf1a810e98b6a3001d0d7234864ed75a17c42fb - - run: cargo check --all-targets + - run: cargo check --locked --all-targets offline-guard: name: offline-by-default (FR-002, FR-017) @@ -67,10 +113,13 @@ jobs: - uses: Swatinem/rust-cache@baf1a810e98b6a3001d0d7234864ed75a17c42fb # The binary must remain hermetic: no HTTP/TLS/socket crate may enter the tree. # `licet` ships SPDX texts embedded, so any such dependency is a regression. + # The tree is produced to a file FIRST so a failed cargo invocation fails + # the check itself instead of grepping an empty pipe. - name: Assert no network-capable crates run: | + cargo tree --locked --edges normal --prefix none | sort -u > dependency-tree.txt banned='reqwest|hyper|^ureq|curl|native-tls|openssl-sys|^rustls|^tokio ' - if cargo tree --edges normal --prefix none | sort -u | grep -iE "$banned"; then + if grep -iE "$banned" dependency-tree.txt; then echo "::error::a network-capable crate entered the dependency tree (offline-by-default violated)" exit 1 fi diff --git a/CLAUDE.md b/CLAUDE.md index b3b022f..6db8fcd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,8 +84,8 @@ Supporting modules: from `assets/licenses/*.txt` into `OUT_DIR/licet_licenses.rs`, `include!`d here. - `src/report/` — `Report` JSON model (matches `contracts/report.schema.json`) and the human renderer. -- `src/walk/cache.rs` — scan cache keyed by content+config+SPDX-version, stored in - `.git/licet-cache` so it never dirties the working tree. +- `src/walk/` — file enumeration with no scan cache: every run classifies from + current bytes (stateless), so repeat runs are identical by construction. ### Adding a bundled license diff --git a/Cargo.lock b/Cargo.lock index 7c1764f..4021c9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,12 +20,6 @@ dependencies = [ "cc", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "anes" version = "0.1.6" @@ -88,21 +82,6 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" -[[package]] -name = "arc-swap" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" -dependencies = [ - "rustversion", -] - -[[package]] -name = "arrayvec" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" - [[package]] name = "assert_cmd" version = "2.2.2" @@ -124,36 +103,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" -[[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 = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - [[package]] name = "borsh" version = "1.7.0" @@ -181,12 +136,6 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.12.0" @@ -297,15 +246,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "clru" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" -dependencies = [ - "hashbrown 0.16.1", -] - [[package]] name = "colorchoice" version = "1.0.5" @@ -323,39 +263,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - [[package]] name = "criterion" version = "0.8.2" @@ -391,15 +298,6 @@ dependencies = [ "itertools", ] -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -431,104 +329,12 @@ 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 = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "dashmap" -version = "6.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "defmt" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" -dependencies = [ - "bitflags 1.3.2", - "defmt-macros", -] - -[[package]] -name = "defmt-macros" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" -dependencies = [ - "defmt-parser", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "defmt-parser" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" -dependencies = [ - "thiserror", -] - [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "const-oid", - "crypto-common 0.2.2", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "either" version = "1.16.0" @@ -542,898 +348,75 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "faster-hex" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" -dependencies = [ - "heapless", - "serde", -] - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "float-cmp" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" -dependencies = [ - "num-traits", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi", -] - -[[package]] -name = "gix" -version = "0.85.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa8b2e38ebfc4484dfef8580ddcaf8abb7285e6f3eb6413ff6775d104ae96ca6" -dependencies = [ - "gix-actor", - "gix-attributes", - "gix-command", - "gix-commitgraph", - "gix-config", - "gix-date", - "gix-diff", - "gix-dir", - "gix-discover", - "gix-error", - "gix-features", - "gix-filter", - "gix-fs", - "gix-glob", - "gix-hash", - "gix-hashtable", - "gix-ignore", - "gix-index", - "gix-lock", - "gix-object", - "gix-odb", - "gix-pack", - "gix-path", - "gix-pathspec", - "gix-protocol", - "gix-ref", - "gix-refspec", - "gix-revision", - "gix-revwalk", - "gix-sec", - "gix-shallow", - "gix-status", - "gix-submodule", - "gix-tempfile", - "gix-trace", - "gix-traverse", - "gix-url", - "gix-utils", - "gix-validate", - "gix-worktree", - "gix-worktree-stream", - "nonempty", - "smallvec", - "thiserror", -] - -[[package]] -name = "gix-actor" -version = "0.41.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bc998b8f746dda8565450d08a63b792ced9165d8c27a1ed3f02799ec6a7820f" -dependencies = [ - "bstr", - "gix-date", - "gix-error", -] - -[[package]] -name = "gix-attributes" -version = "0.33.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39b40888d0ed415c0744a6cdc61eebf0304c9d26ab726725b718443c322e5ba4" -dependencies = [ - "bstr", - "gix-glob", - "gix-path", - "gix-quote", - "gix-trace", - "kstring", - "smallvec", - "thiserror", - "unicode-bom", -] - -[[package]] -name = "gix-bitmap" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ebef0c26ad305747649e727bbcd56a7b7910754eb7cea88f6dff6f93c51283" -dependencies = [ - "gix-error", -] - -[[package]] -name = "gix-chunk" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9faee47943b638e58ddd5e275a4906ad3e4b6c8584f1d41bd18ab9032ec52afb" -dependencies = [ - "gix-error", -] - -[[package]] -name = "gix-command" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00706d4fef135ef4b01680d5218c6ee40cda8baf697b864296cbc887d19118f6" -dependencies = [ - "bstr", - "gix-path", - "gix-quote", - "gix-trace", - "shell-words", -] - -[[package]] -name = "gix-commitgraph" -version = "0.37.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f675d0df484a7f6a47e64bd6f311af489d947c0323b0564f36d14f3d7762abb" -dependencies = [ - "bstr", - "gix-chunk", - "gix-error", - "gix-hash", - "memmap2", - "nonempty", -] - -[[package]] -name = "gix-config" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a29bf266c4cdaf759e535c24ad4ce655b987aeb6911075643403cc7cc5ade583" -dependencies = [ - "bstr", - "gix-config-value", - "gix-features", - "gix-glob", - "gix-path", - "gix-ref", - "gix-sec", - "smallvec", - "thiserror", - "unicode-bom", -] - -[[package]] -name = "gix-config-value" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed42168329552f6c2e5df09665c104199d45d84bedb53683738a49b57fe1baab" -dependencies = [ - "bitflags 2.13.0", - "bstr", - "gix-path", - "libc", - "thiserror", -] - -[[package]] -name = "gix-date" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d63f9e28b59ddeb1a1eb9e5cf986a9222b5d484947445edbc20473939cc7fd0" -dependencies = [ - "bstr", - "gix-error", - "itoa", - "jiff", -] - -[[package]] -name = "gix-diff" -version = "0.65.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92c6d56c94edf92d78203a1cd416f770e35e10b6955ede6b9d7d0c22ff88a5f3" -dependencies = [ - "bstr", - "gix-attributes", - "gix-command", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-imara-diff", - "gix-index", - "gix-object", - "gix-path", - "gix-pathspec", - "gix-tempfile", - "gix-trace", - "gix-traverse", - "gix-worktree", - "thiserror", -] - -[[package]] -name = "gix-dir" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20098fba2b9c6e29361ccb4c0379d42dfb81fc7f86f7ab11f2dff4528c0bf01f" -dependencies = [ - "bstr", - "gix-discover", - "gix-fs", - "gix-ignore", - "gix-index", - "gix-object", - "gix-path", - "gix-pathspec", - "gix-trace", - "gix-utils", - "gix-worktree", - "thiserror", -] - -[[package]] -name = "gix-discover" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d624d5b23b10c1d85337645227abe353ac95ab8ff66a7bdd5ce689b2db33a722" -dependencies = [ - "bstr", - "dunce", - "gix-fs", - "gix-path", - "gix-ref", - "gix-sec", - "thiserror", -] - -[[package]] -name = "gix-error" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57831e199be480af90dcd7e459abed8a174c09ec9a6e2cc8f7ca6c54598b06b" -dependencies = [ - "bstr", -] - -[[package]] -name = "gix-features" -version = "0.48.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1849ae154d38bc403185be14fa871e38e3c93ee606875d94e207fdb9fba52dbc" -dependencies = [ - "bytes", - "crc32fast", - "crossbeam-channel", - "gix-path", - "gix-trace", - "gix-utils", - "libc", - "once_cell", - "parking_lot", - "prodash", - "thiserror", - "walkdir", - "zlib-rs", -] - -[[package]] -name = "gix-filter" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6644fb2ef97928c278675b239f366b457103d7e436f811d27331a8daf212759c" -dependencies = [ - "bstr", - "encoding_rs", - "gix-attributes", - "gix-command", - "gix-hash", - "gix-object", - "gix-packetline", - "gix-path", - "gix-quote", - "gix-trace", - "gix-utils", - "smallvec", - "thiserror", -] - -[[package]] -name = "gix-fs" -version = "0.21.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cdff46db8798e47e2f727d84b9379aac5add3dd3d9d0b07bb4d7d5d640771fe" -dependencies = [ - "bstr", - "fastrand", - "gix-features", - "gix-path", - "gix-utils", - "thiserror", -] - -[[package]] -name = "gix-glob" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1fcb8ef5b16bcf874abe9b68d8abb3c0493c876d367ab824151f30a0f3f3756" -dependencies = [ - "bitflags 2.13.0", - "bstr", - "gix-features", - "gix-path", -] - -[[package]] -name = "gix-hash" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb0926d3819c837750b4e03c7754901e73f68b8c9b690753a6372a1bed4eedce" -dependencies = [ - "faster-hex", - "gix-features", - "sha1-checked", - "thiserror", -] - -[[package]] -name = "gix-hashtable" -version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e261d54091f0d1c729bc83f54548c071bdec60a697de1e58e88bdfd7a99d24e" -dependencies = [ - "gix-hash", - "hashbrown 0.17.1", - "parking_lot", -] - -[[package]] -name = "gix-ignore" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d491bab9bf2c9f341dc754f425c31d5d3f63aca615312167b82e1deeaca97d8d" -dependencies = [ - "bstr", - "gix-glob", - "gix-path", - "gix-trace", - "unicode-bom", -] - -[[package]] -name = "gix-imara-diff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b305d85504de270ad3525d726a6b69cc59ee7b2269b014387651107ab9f0755b" -dependencies = [ - "bstr", - "hashbrown 0.17.1", -] - -[[package]] -name = "gix-index" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36d45f82ec5a4d7542ea595e9ad16e03e26c8cb4f221e5bc9fcdcf469f63a681" -dependencies = [ - "bitflags 2.13.0", - "bstr", - "filetime", - "fnv", - "gix-bitmap", - "gix-features", - "gix-fs", - "gix-hash", - "gix-lock", - "gix-object", - "gix-traverse", - "gix-utils", - "gix-validate", - "hashbrown 0.17.1", - "itoa", - "libc", - "memmap2", - "rustix", - "smallvec", - "thiserror", -] - -[[package]] -name = "gix-lock" -version = "23.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c9dedd9e90b0d47624d2ed241d394e09294118364e87b9b7e5f1fe755f3c2c" -dependencies = [ - "gix-tempfile", - "gix-utils", - "thiserror", -] - -[[package]] -name = "gix-object" -version = "0.62.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "019b38afc3eac1e41f9fe09a327664b313ba4a120fa5f40e3678795d0e42783e" -dependencies = [ - "bstr", - "gix-actor", - "gix-date", - "gix-features", - "gix-hash", - "gix-hashtable", - "gix-utils", - "gix-validate", - "itoa", - "smallvec", - "thiserror", -] - -[[package]] -name = "gix-odb" -version = "0.82.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fadc59f6fa0f9dd445eceee61060a2b59ca557f48da9fc677f567db535b782a" -dependencies = [ - "arc-swap", - "gix-features", - "gix-fs", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-pack", - "gix-path", - "gix-quote", - "memmap2", - "parking_lot", - "tempfile", - "thiserror", -] - -[[package]] -name = "gix-pack" -version = "0.72.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3e7f1726cd2c0cd1cf1fc20be8a8e623f0b163f1f8d6fc836cfb9bc8cd758b" -dependencies = [ - "clru", - "gix-chunk", - "gix-error", - "gix-features", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-path", - "memmap2", - "smallvec", - "thiserror", - "uluru", -] - -[[package]] -name = "gix-packetline" -version = "0.21.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b217dd0ee0c4021ecf169a4a519b1b4f80d15e3f3765f3dc466223dc0ac891d7" -dependencies = [ - "bstr", - "faster-hex", - "gix-trace", - "thiserror", -] - -[[package]] -name = "gix-path" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afa6ac14cd14939ea94a496ce7460daa6511c09f5b84757e9cfc6f9c8d0f93a6" -dependencies = [ - "bstr", - "gix-trace", - "gix-validate", - "thiserror", -] - -[[package]] -name = "gix-pathspec" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3050783b41ee11511e1e8fb35623df81806194f4030395f14f48ea37c2798c9f" -dependencies = [ - "bitflags 2.13.0", - "bstr", - "gix-attributes", - "gix-config-value", - "gix-glob", - "gix-path", - "thiserror", -] - -[[package]] -name = "gix-protocol" -version = "0.63.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978468bae4ea2df20c72db3b20d0bdb548a0c1090b85a83643b553e6e0e041f2" -dependencies = [ - "bstr", - "gix-date", - "gix-features", - "gix-hash", - "gix-ref", - "gix-shallow", - "gix-transport", - "gix-utils", - "maybe-async", - "nonempty", - "thiserror", -] - -[[package]] -name = "gix-quote" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef" -dependencies = [ - "bstr", - "gix-error", - "gix-utils", -] - -[[package]] -name = "gix-ref" -version = "0.65.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bbfbce1dfd7d7f8469ddef6d3518376aff664348f153cbe0fc3e58ef993d24e" -dependencies = [ - "gix-actor", - "gix-features", - "gix-fs", - "gix-hash", - "gix-lock", - "gix-object", - "gix-path", - "gix-tempfile", - "gix-utils", - "gix-validate", - "memmap2", - "thiserror", -] - -[[package]] -name = "gix-refspec" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bc36a4fb1a1540b59cf2da498783080743fa274b02a3f19ca444fc4015a9d4f" -dependencies = [ - "bstr", - "gix-error", - "gix-glob", - "gix-hash", - "gix-revision", - "gix-validate", - "smallvec", - "thiserror", -] - -[[package]] -name = "gix-revision" -version = "0.47.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "885075c3c21eb9c06e0be3b3728ba5932c04e1c1011dcee7c81801980e3e986f" -dependencies = [ - "bitflags 2.13.0", - "bstr", - "gix-commitgraph", - "gix-date", - "gix-error", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-revwalk", - "gix-trace", - "nonempty", -] - -[[package]] -name = "gix-revwalk" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f11fe7ca2585193d3d70bbe0be175a2008d883a704cc7a55e454e113e689455" -dependencies = [ - "gix-commitgraph", - "gix-date", - "gix-error", - "gix-hash", - "gix-hashtable", - "gix-object", - "smallvec", - "thiserror", -] - -[[package]] -name = "gix-sec" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8519976e4c7e486270740a5400369f37940779b80bd1377d94cfa1125d01b3" -dependencies = [ - "bitflags 2.13.0", - "gix-path", - "libc", - "windows-sys", -] - -[[package]] -name = "gix-shallow" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a292fc2fe548c5dfa575479d16b445b0ddf1dd2f56f1fec6aed386f82553cd97" -dependencies = [ - "bstr", - "gix-hash", - "gix-lock", - "nonempty", - "thiserror", -] - -[[package]] -name = "gix-status" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aec3293f75db1212f99217832cbc70c30faeb95cefc97c7f1a17fd3bcf13a72e" -dependencies = [ - "bstr", - "filetime", - "gix-diff", - "gix-dir", - "gix-features", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-index", - "gix-object", - "gix-path", - "gix-pathspec", - "gix-worktree", - "portable-atomic", - "thiserror", -] - -[[package]] -name = "gix-submodule" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f9f594f7cbda0b38ba6b633b3e9a7b7901acdc5d27bc186a16633800cd1ac8" -dependencies = [ - "bstr", - "gix-config", - "gix-path", - "gix-pathspec", - "gix-refspec", - "gix-url", - "thiserror", -] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "gix-tempfile" -version = "23.0.2" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef60812443484e67bf84e444cc71b4c78ae62deb822221774a4fa0c57fdb17f" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "dashmap", - "gix-fs", "libc", - "parking_lot", - "tempfile", + "windows-sys", ] [[package]] -name = "gix-trace" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44dc45eae785c0eb14173e0f152e6e224dcf4d45b6a6999a3aed22af541ad678" - -[[package]] -name = "gix-transport" -version = "0.57.2" +name = "fastrand" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186874f7ad1fb2f9a2f2aa9c2dabc7f9dd087bef74c1a0eee2b4a9cf0248fcb3" -dependencies = [ - "bstr", - "gix-command", - "gix-features", - "gix-packetline", - "gix-quote", - "gix-sec", - "gix-url", - "thiserror", -] +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] -name = "gix-traverse" -version = "0.59.0" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5062cca8f2977565bbaf666ec31dbdb9bc9d9293beb65f9bec52e6c1121b62a1" -dependencies = [ - "bitflags 2.13.0", - "gix-commitgraph", - "gix-date", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-revwalk", - "smallvec", - "thiserror", -] +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "gix-url" -version = "0.36.1" +name = "float-cmp" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bb01ec69d55e82ccb7a19e264501ead4e6aac38463a8cebfdd81e22bb67ab2" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" dependencies = [ - "bstr", - "gix-path", - "percent-encoding", - "thiserror", + "num-traits", ] [[package]] -name = "gix-utils" -version = "0.3.3" +name = "futures-core" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66c50966184123caf580ffa64e28031a878597f1c7fceb8fe19566c38eb1b771" -dependencies = [ - "bstr", - "fastrand", - "unicode-normalization", -] +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] -name = "gix-validate" -version = "0.11.2" +name = "futures-task" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bc6fc771c4063ba7cd2f47b91fb6076251c6a823b64b7fe7b8874b0fe4afae3" -dependencies = [ - "bstr", -] +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] -name = "gix-worktree" -version = "0.54.0" +name = "futures-util" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92399ed66f259592050c6ed9dc80105e095a2f8e87e6b83d98aa2e21d8e27036" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "bstr", - "gix-attributes", - "gix-fs", - "gix-glob", - "gix-hash", - "gix-ignore", - "gix-index", - "gix-object", - "gix-path", - "gix-validate", + "futures-core", + "futures-task", + "pin-project-lite", + "slab", ] [[package]] -name = "gix-worktree-stream" -version = "0.34.0" +name = "getrandom" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55f3a878c89a05470ad98c644b0015777c530da24854dd29e41fe4f41176840f" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ - "gix-attributes", - "gix-error", - "gix-features", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-object", - "gix-path", - "gix-traverse", - "parking_lot", + "cfg-if", + "libc", + "r-efi", ] [[package]] @@ -1460,52 +443,11 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "hash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" -dependencies = [ - "byteorder", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "heapless" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" -dependencies = [ - "hash32", - "stable_deref_trait", -] [[package]] name = "heck" @@ -1513,15 +455,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hybrid-array" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" -dependencies = [ - "typenum", -] - [[package]] name = "ignore" version = "0.4.26" @@ -1545,7 +478,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.1", + "hashbrown", ] [[package]] @@ -1581,48 +514,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jiff" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46" -dependencies = [ - "defmt", - "jiff-static", - "jiff-tzdb-platform", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", - "windows-link", -] - -[[package]] -name = "jiff-static" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "jiff-tzdb" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" - -[[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" -dependencies = [ - "jiff-tzdb", -] - [[package]] name = "js-sys" version = "0.3.103" @@ -1634,15 +525,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "kstring" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" -dependencies = [ - "static_assertions", -] - [[package]] name = "libc" version = "0.2.186" @@ -1660,7 +542,6 @@ dependencies = [ "clap", "clap_complete", "criterion", - "gix", "globset", "ignore", "insta", @@ -1669,7 +550,6 @@ dependencies = [ "rayon", "serde", "serde_json", - "sha2", "smallvec", "smol_str", "spdx", @@ -1684,53 +564,18 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" -[[package]] -name = "maybe-async" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "memchr" version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" -[[package]] -name = "memmap2" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" -dependencies = [ - "libc", -] - -[[package]] -name = "nonempty" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" - [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -1774,35 +619,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1837,21 +653,6 @@ dependencies = [ "plotters-backend", ] -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "portable-atomic-util" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" -dependencies = [ - "portable-atomic", -] - [[package]] name = "predicates" version = "3.1.4" @@ -1882,28 +683,6 @@ dependencies = [ "termtree", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -1913,15 +692,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "prodash" -version = "31.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" -dependencies = [ - "parking_lot", -] - [[package]] name = "quote" version = "1.0.46" @@ -1957,15 +727,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.13.0", -] - [[package]] name = "regex" version = "1.12.4" @@ -2001,7 +762,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -2023,12 +784,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "serde" version = "1.0.228" @@ -2081,44 +836,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha1-checked" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" -dependencies = [ - "digest 0.10.7", - "sha1", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - [[package]] name = "shlex" version = "2.0.1" @@ -2162,18 +879,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "strsim" version = "0.11.1" @@ -2240,21 +945,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -2294,54 +984,18 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "uluru" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "unicode-bom" -version = "2.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" - [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "wait-timeout" version = "0.2.1" @@ -2488,12 +1142,6 @@ dependencies = [ "syn", ] -[[package]] -name = "zlib-rs" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3" - [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 5e3e924..3218cae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,18 +35,13 @@ memchr = "2.8.2" rayon = "1.12.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.104" -sha2 = "0.11.0" smallvec = "1.15.2" smol_str = "0.3.6" +tempfile = "3.27.0" spdx = "0.13.4" thiserror = "2.0.18" toml = "1.1.2" -[dependencies.gix] -version = "0.85.0" -default-features = false -features = ["dirwalk", "max-performance-safe", "revision", "sha1", "status"] - [dev-dependencies] assert_cmd = "2.2.2" criterion = "0.8.2" diff --git a/README.md b/README.md index 75ddc71..3f10aea 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ licet apply --dry-run # print the full plan without writing `apply` refuses to modify a dirty working tree unless `--allow-dirty`, so `git checkout` is always a clean undo. Writes are atomic (temp-file + rename). Missing standard license texts are materialized into `LICENSES/` from the offline bundle; `LicenseRef-*` texts are -scaffolded as placeholders. +never invented — the maintainer supplies them. ### `init` / `lint` @@ -108,8 +108,10 @@ licet add-license --all # every referenced-but-missing text Unlike `apply`, it writes **only** under `LICENSES/` — it never edits source files or `license.toml`, so it does not require a clean working tree. `LicenseRef-*` ids are -scaffolded as placeholders. Exit `0` on success, `1` if a requested text can't be supplied -offline, `2` on flag misuse (neither ids nor `--all`, or both). +reported missing (exit `1`) and never scaffolded: custom texts come from the maintainer. +A successful run writes exactly the requested text files, one per id. Exit `0` on +success, `1` if a requested text can't be supplied offline, `2` on flag misuse +(neither ids nor `--all`, or both). ### Shell completions @@ -126,8 +128,8 @@ Supported shells: `bash`, `zsh`, `fish`, `powershell`, `elvish`. - **Binary**: `licet` (one self-contained executable; see [Install](#install)). - **Config**: `license.toml` at the repo root — the sole authoring surface, overridable with `--config `. `REUSE.toml` / `.reuse/dep5` are read for interop only. -- **Cache**: stored under `.git/licet-cache` so it never dirties the working tree; - relocate with `--cache ` or disable with `--no-cache`. +- **No cache**: scans are stateless and never create files; `--cache` / `--no-cache` + remain only as deprecated no-ops. ## Behavior guarantees @@ -136,8 +138,8 @@ Supported shells: `bash`, `zsh`, `fish`, `powershell`, `elvish`. - **Copyright-safe**: copyright/authorship preserved across a license-only replace. - **Detection precedence**: when an in-file header and out-of-band metadata disagree, the out-of-band value wins and a non-failing `source_override` warning is emitted. -- **Cache fidelity**: the scan cache key folds in file content + effective config + tool - version, so a hit is observationally identical to a cold run. +- **Stateless scans**: every run classifies from current bytes, so repeat runs are + observationally identical by construction — no stale verdicts possible. - **Line endings**: LF/CRLF preserved on write; non-UTF-8 files are never byte-edited. ## Development @@ -164,7 +166,19 @@ cargo release patch # bump version, regenerate CHANGELOG.md, commit, tag vX.Y. Pushing the `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which builds a single self-contained binary for Linux/macOS/Windows (x86-64 + arm64; Linux is static musl), attaches SHA-256 checksums, and publishes a GitHub Release whose notes are generated by -git-cliff from the Conventional Commit history. +git-cliff from the Conventional Commit history. The tag must match `Cargo.toml` +(a `version-guard` job fails the release otherwise); crates.io publishing uses OIDC +trusted publishing after the whole binary matrix is green. No tag or registry +publication is part of ordinary development. + +### Compatibility and versions + +- Tool versions are strict `x.y.z` semver; `licet --version` prints + `licet (SPDX license list )` with the embedded list snapshot. +- The normative target is REUSE 3.3; conformance is checked against the pinned + reference `reuse[charset-normalizer]==6.2.0` (see `tests/reuse_differential.rs`). +- The embedded SPDX list snapshot defaults to `3.25-bundled` and can be + overridden at build time with `LICET_SPDX_LIST_VERSION` (offline either way). The engine is a library (`src/lib.rs`) decomposed by pipeline stage — `walk` → `detect` + `rules` → `report` (classify) → `reconcile` — with `config`, `comment`, diff --git a/benches/scan.rs b/benches/scan.rs index 10d7532..2bca931 100644 --- a/benches/scan.rs +++ b/benches/scan.rs @@ -1,11 +1,12 @@ -//! T048 — scan-throughput benchmark (SC-006 budget tracking). +//! Scan-throughput benchmark: the stateless engine over synthetic trees. //! -//! Drives the in-process engine (`Engine::scan`) over a synthetic tree, measuring both the -//! cold path (cache disabled) and the warm path (cache loaded from disk). This is a trend -//! tracker, not a hard gate — the spec's 10k-file <1s warm / <3s cold bars are asserted on -//! the reference runner; here we watch for regressions in relative throughput. +//! No cache artifact exists anymore — every iteration classifies from current +//! bytes, so "cold" and "warm" are the same code path (filesystem page cache +//! aside). This is a trend tracker, not a hard gate: count/status equality is +//! asserted once outside the timed loops, and engine-only timing is kept +//! separate from end-to-end CLI timing (see tests/perf.rs for the CLI side). //! -//! Run with `cargo bench`; results land under `target/criterion/`. +//! Run with `cargo bench --bench scan`; results land under `target/criterion/`. // REUSE-IgnoreStart — SPDX tags below are test fixtures, not this file's licensing. use std::path::Path; @@ -15,13 +16,13 @@ use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_m use licet::config::LicensingConfiguration; use licet::engine::Engine; use licet::walk::Selection; -use licet::walk::cache::{ScanCache, config_fingerprint}; +use licet::walk::{Discovered, Purpose, prepare}; -const CONFIG: &str = "[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n"; +const BASE_CONFIG: &str = "[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n"; -/// Populate `root` with `n` compliant Rust files spread across subdirectories. -fn build_tree(root: &Path, n: usize) { - std::fs::write(root.join("license.toml"), CONFIG).unwrap(); +/// Populate `root` with `n` compliant tiny Rust files across subdirectories. +fn build_tiny_tree(root: &Path, n: usize) { + std::fs::write(root.join("license.toml"), BASE_CONFIG).unwrap(); for i in 0..n { let dir = root.join(format!("src/d{}", i / 100)); std::fs::create_dir_all(&dir).unwrap(); @@ -33,44 +34,142 @@ fn build_tree(root: &Path, n: usize) { } } -fn bench_scan(c: &mut Criterion) { - let config = LicensingConfiguration::from_toml(CONFIG).unwrap(); - let fingerprint = config_fingerprint(CONFIG); +/// A mixed tree: late snippets in large texts, binary sidecars, nested +/// metadata, and dozens of rules — the workloads a head-only scanner skipped. +fn build_mixed_tree(root: &Path, n: usize) -> String { + let mut config = String::from(BASE_CONFIG); + for i in 0..24 { + config.push_str(&format!( + "[[rule]]\nglob=\"src/d{i}/**\"\nlicense=\"MIT\"\n" + )); + } + std::fs::write(root.join("license.toml"), &config).unwrap(); + std::fs::create_dir_all(root.join("sub")).unwrap(); + std::fs::write( + root.join("sub/REUSE.toml"), + "version = 1\n[[annotations]]\npath = \"*.dat\"\nSPDX-License-Identifier = \"MIT\"\n", + ) + .unwrap(); + for i in 0..n { + let dir = root.join(format!("src/d{}", i / 100)); + std::fs::create_dir_all(&dir).unwrap(); + match i % 5 { + // Late snippet just before EOF in an 8 KiB file. + 0 => { + let mut body = String::from("// SPDX-License-Identifier: MIT\n"); + while body.len() < 8 * 1024 { + body.push_str("// filler line to push the snippet late\n"); + } + body.push_str("// SPDX-SnippetBegin: s1\n// SPDX-License-Identifier: MIT\n// SPDX-SnippetEnd: s1\n"); + std::fs::write(dir.join(format!("f{i}.rs")), body).unwrap(); + } + // Binary asset covered by a sidecar. + 1 => { + std::fs::write(dir.join(format!("f{i}.bin")), [0x00, 0xFF, 0x89]).unwrap(); + std::fs::write( + dir.join(format!("f{i}.bin.license")), + "SPDX-License-Identifier: MIT\nSPDX-FileCopyrightText: 2026 Bench\n", + ) + .unwrap(); + } + // File covered by nested metadata. + 2 => { + let sub = root.join("sub"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(sub.join(format!("f{i}.dat")), "opaque\n").unwrap(); + } + _ => { + std::fs::write( + dir.join(format!("f{i}.rs")), + "// SPDX-License-Identifier: MIT\nfn f() {}\n", + ) + .unwrap(); + } + } + } + config +} + +fn prepared_paths( + root: &Path, +) -> ( + LicensingConfiguration, + Vec, + licet::walk::Snapshot, +) { + let prep = prepare( + root, + &root.join("license.toml"), + &Selection::FullTree, + Purpose::Policy, + false, + ) + .unwrap(); + let config = LicensingConfiguration::from_toml(&prep.config_text).unwrap(); + (config, prep.paths, prep.snapshot) +} - let mut group = c.benchmark_group("scan"); - for &n in &[500usize, 2000] { +/// Assert the evaluated set is exactly what the timed loop will classify: +/// same file count, every file compliant or excluded. Runs once, outside +/// timing. (Excluded files such as `license.toml` stay in the path set with +/// an `Excluded` verdict — they are part of the equivalent set.) +fn assert_equivalent(engine: &Engine, paths: &[Discovered]) { + let result = engine.scan(paths).unwrap(); + assert_eq!(result.states.len(), paths.len(), "equivalent file set"); + assert!( + result.states.iter().all(|s| matches!( + s.drift, + licet::domain::DriftClass::Compliant | licet::domain::DriftClass::Excluded + )), + "all compliant or excluded outside the timed loop" + ); +} + +fn bench_scan(c: &mut Criterion) { + for &n in &[500usize, 2000, 10_000] { let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().to_path_buf(); - build_tree(&root, n); - let cache_path = root.join(".licet-bench-cache"); + build_tiny_tree(&root, n); + let (config, paths, snapshot) = prepared_paths(&root); + assert_eq!(paths.len(), n + 1, "n files plus the excluded config"); - group.throughput(Throughput::Elements(n as u64)); + let engine = Engine::new(root.clone(), &config, snapshot, true); + assert_equivalent(&engine, &paths); + let mut iter_group = c.benchmark_group(format!("scan/tiny/{n}")); + iter_group.throughput(Throughput::Elements(n as u64)); + if n >= 10_000 { + iter_group.sample_size(10); + } + iter_group.bench_function(BenchmarkId::new("engine", n), |b| { + b.iter(|| engine.scan(&paths).unwrap()); + }); + iter_group.finish(); + } - // Cold: cache disabled, every file fully classified. - group.bench_with_input(BenchmarkId::new("cold", n), &n, |b, _| { - let engine = Engine::new(root.clone(), &config, CONFIG); - b.iter(|| { - let mut cache = ScanCache::disabled(); - engine.scan(&Selection::FullTree, &mut cache).unwrap(); - }); + // Mixed workload at one representative size. + { + let n = 2000usize; + let tmp = tempfile::TempDir::new().unwrap(); + let root = tmp.path().to_path_buf(); + build_mixed_tree(&root, n); + let (config, paths, snapshot) = prepared_paths(&root); + let engine = Engine::new(root.clone(), &config, snapshot, true); + assert_equivalent(&engine, &paths); + let mut mixed = c.benchmark_group("scan/mixed/2000"); + mixed.throughput(Throughput::Elements(paths.len() as u64)); + mixed.bench_function("engine", |b| { + b.iter(|| engine.scan(&paths).unwrap()); }); + mixed.finish(); - // Warm: prime an on-disk cache once, then measure load + scan per iteration. - { - let engine = Engine::new(root.clone(), &config, CONFIG); - let mut warm = ScanCache::open(&cache_path, &fingerprint); - engine.scan(&Selection::FullTree, &mut warm).unwrap(); - warm.flush().ok(); - } - group.bench_with_input(BenchmarkId::new("warm", n), &n, |b, _| { - let engine = Engine::new(root.clone(), &config, CONFIG); - b.iter(|| { - let mut cache = ScanCache::open(&cache_path, &fingerprint); - engine.scan(&Selection::FullTree, &mut cache).unwrap(); - }); + // One-file scan after a full run (the staged-check shape). + let one = &paths[..1.min(paths.len())]; + let mut single = c.benchmark_group("scan/one_file"); + single.bench_function("engine", |b| { + b.iter(|| engine.scan(one).unwrap()); }); + single.finish(); } - group.finish(); } criterion_group!(benches, bench_scan); diff --git a/build.rs b/build.rs index c5ae1e7..0a9de31 100644 --- a/build.rs +++ b/build.rs @@ -12,6 +12,9 @@ fn main() { let dest = Path::new(&out_dir).join("licet_licenses.rs"); println!("cargo:rerun-if-changed=assets/licenses"); + // The SPDX list version is build metadata: a changed override must + // rebuild, or the binary would report a stale version string. + println!("cargo:rerun-if-env-changed=LICET_SPDX_LIST_VERSION"); let mut entries: Vec = Vec::new(); if licenses_dir.is_dir() { diff --git a/docs/2026-09-10-licet-audit.md b/docs/2026-09-10-licet-audit.md new file mode 100644 index 0000000..a183864 --- /dev/null +++ b/docs/2026-09-10-licet-audit.md @@ -0,0 +1,247 @@ +# Licet application audit + +Date: 2026-09-10. Scope: the source snapshot in `/opt/coder/licet`, package version **0.1.4**. This is an analysis and implementation handoff; application code was not changed. + +Licet has a useful product model and a reasonably small pipeline, but its current success results are not reliable enough for unattended license changes or a REUSE compliance gate. The largest problems are incorrect source selection, lossy metadata handling, unsafe file replacement, and tests that establish internal consistency without exercising the problematic cases. Raw throughput is already adequate on the simple workload measured here. A rewrite, plugin system, database, or new caching architecture would be premature. + +Read the [implementation plan](superpowers/plans/2026-09-10-licet-improvements.md) to execute the work. It specifies behavior decisions, file ownership, dependencies, regression cases, and completion gates. The plan deliberately resolves contradictions in the older contracts rather than asking its executor to infer policy from inconsistent comments. + +## Evidence and limits + +The checkout has no `.git` metadata, so no commit SHA or remote release state can be established from it. Git-dependent experiments used disposable repositories. The build and all reproductions used this checkout's binary, not the separately installed Licet version from mise. + +Source-set SHA-256: `88c10c2f1b126b8786d08a48cda98ece192e830bda0bbbd005b846c3f20bae77`. This hashes 67 files: all files under `src`, `tests`, `assets/licenses`, and `.github/workflows`, plus `Cargo.toml`, `Cargo.lock`, and `build.rs`; in sorted relative-path order, feed `path + NUL + contents + NUL` to SHA-256. Documentation added by this audit is outside that set. + +| Verification | Observed result | +|---|---| +| `cargo test --locked` | 152 tests passed: 94 unit tests and 58 integration tests. Zero doctests. | +| `cargo fmt --all --check` | Passed. | +| `cargo clippy --locked --all-targets -- -D warnings` | Passed. | +| `cargo +1.89 check --locked --offline --all-targets` | Passed; the initial default-target check also passed. | +| `cargo bench --locked --bench scan -- --quick` | Completed; measurements below. | +| Existing REUSE comparison, initial run | Silently skipped because installed `reuse --version` failed with `NoEncodingModuleError`. | +| Existing comparison after isolated tool repair | `reuse_conformance_after_apply` passed with REUSE 6.2.0 and an encoding extra. | +| Five adversarial REUSE fixtures | All five disagreements confirmed against REUSE 6.2.0, which reports specification 3.3. | + +The primary toolchain was Rust 1.100.0-nightly on macOS arm64. Cargo initially needed missing locked dependencies; after their download the exact test command succeeded. One subsequent fixture run encountered inherited Git commit signing and was rerun with signing disabled **only in the test process**. Neither failure was treated as an application bug. The test helper's dependence on global Git configuration is an improvement opportunity. + +The REUSE comparison used `reuse[charset-normalizer]==6.2.0` in `/private/tmp/licet-reuse-comparator-venv`, without modifying the global installation. The supplied [REUSE 3.3 document](REUSE_Specification_v3.3.md) was checked against the [official specification](https://reuse.software/spec-3.3/). Workflow findings are source reviews, not claims about a live GitHub run, registry trust policy, or released binary. No dependency advisory audit or cross-platform runtime certification was performed. + +## Priority and implementation mapping + +**P1** means fix before using Licet for unattended mutation or authoritative gating. **P2** means required for predictable supported behavior and the promised REUSE 3.3 scope. **P3** means maintenance or delivery improvement. These are engineering priorities, not CVSS scores; filesystem attacks require malicious local repository content, a supplied argument, or another process able to place paths. + +| ID | Priority | Finding | Plan task | +|---|---|---|---| +| F01 | P1 | Predictable temporary file and permission loss | 1 | +| F02 | P1 | License downloads/materialization escape their destination | 1 | +| F03 | P1 | Apply exits successfully with residual failures | 7 | +| F04 | P1 | REUSE metadata writing can destroy or invalidate existing coverage | 1, 6 | +| F05 | P1 | Header editing can break source syntax; additive mode is inconsistent | 6 | +| F06 | P1 | Staged checks inspect unstaged bytes | 2 | +| F07 | P1 | File selection is inconsistent and corrupts Git filenames | 2 | +| F08 | P1 | Lint produces false REUSE compliance results | 3, 4, 5 | +| F09 | P1 | Valid REUSE metadata is ignored or matches the wrong files | 4 | +| F10 | P1 | Init can infer unintended relicensing and overwrite authoring intent | 8 | +| F11 | P2 | Copyright policies are not reconciled | 5, 6 | +| F12 | P2 | SPDX normalization reparses valid expressions incorrectly and expensively | 3 | +| F13 | P2 | Invalid configuration and I/O errors disappear | 1–5, 9 | +| F14 | P2 | Comment-style configuration can affect the wrong files or emit invalid code | 6, 9 | +| F15 | P2 | Reports and dry runs omit important changes and break machine output | 7, 9 | +| F16 | P2 | Cache costs work but never avoids it | 10 | +| F17 | P2 | Tests and benchmarks omit the important contracts | All tasks, 10, 11 | +| F18 | P2 | Release workflow has authentication and sequencing defects | 12 | +| F19 | P3 | Toolchain, bundle metadata, and comments obscure actual guarantees | 10–12 | + +## Detailed findings + +### F01 — File replacement can overwrite another file and change access permissions + +`src/reuse/mod.rs:18–25` derives a fixed `..licet.tmp` path and opens it with `File::create`, then renames it over the source. A pre-existing regular file at that name is truncated. A symlink at that name is followed while writing, then moved over the source. + +**CLI reproduction:** `.a.rs.licet.tmp` pointed to a sentinel outside the repository. Applying a header to `a.rs` overwrote the sentinel, replaced `a.rs` with that symlink, and exited 0. A separate executable `a.sh` changed from mode `0700` to `0644`. This both removes executable permission and broadens access under the tested umask. + +Use an exclusively created sibling temporary file, preserve source permissions, and reject symlink destinations and unsafe ancestors. A failed write must clean up only the temporary file that this invocation created. Preflight existing metadata reads; a non-UTF-8 or inaccessible file is not an absent file. Concurrent source modifications must be detected before replacement. Atomic replacement is per file; do not promise a repository-wide transaction. + +### F02 — License-text operations do not enforce the LICENSES boundary + +`src/cli/add_license.rs:41–50` accepts arbitrary identifiers. `src/reuse/inventory.rs:152–175` embeds them into a destination path, passes that path to curl, and deletes it on curl failure. With an existing `victim.txt`, `add-license ../victim --allow-curl` and a fake curl that merely exited 22 deleted the victim. No successful network response was necessary. + +Separately, `present_texts` ignores symlink entries (`inventory.rs:53`), while bundled materialization follows them using `std::fs::write` (`:106`). `LICENSES/MIT.txt` pointing outside the repository caused `add-license MIT` to overwrite the external target. Symlinked parent directories have the same containment problem. + +Validate individual license/exception IDs and filename components before any filesystem mutation. Download into owned temporary storage with bounded execution, validate a nonempty text result, and install it using the same safe writer. Never remove or truncate the final destination during failed retrieval. Keep network consent explicit and test subprocess behavior with fake curl. + +### F03 — Exit status and reported outcome disagree + +`src/cli/apply.rs:257–279` fails only on selected drift categories through `has_unfixable`; remaining `MissingHeader`, `WrongLicense`, and rule conflicts do not necessarily affect the exit code. `:237–240` ignores license materialization errors. + +Confirmed examples: + +- A copyright-only first header remains without a license; apply exits **0** with JSON `summary.pass = false` and `missing_header = 1`. +- An annotatable file covered by an overriding Apache-2.0 REUSE annotation, with declared MIT, remains wrong; apply exits **0** with `wrong_license = 1`. +- `LICENSES` exists as an ordinary file. A source header is inserted, license-text creation fails, and apply exits **0** with `summary.pass = true`. + +One result calculation must drive JSON, human output, and process exit status. Plan failures, write failures, residual policy drift, and missing texts must all participate. Partial status must account for license-text and metadata writes as well as source writes. + +### F04 — REUSE output can destroy unrelated metadata + +`src/reuse/oob.rs:224–225` converts every read error to empty content. Existing invalid UTF-8 `REUSE.toml` containing `KEEP\xff` was replaced entirely when a binary asset needed an annotation; exit 0. + +`oob.rs:265–267` writes the copyright key repeatedly rather than once as an array. A valid broad binary annotation with holders Alice and Bob, followed by apply that creates an exact-path exception, produced invalid TOML with duplicate keys. The entire metadata file then stopped parsing, affecting sibling coverage. `:287–362` is a second, incomplete TOML parser that misses literal strings, multiline values, escapes, and table variants. Exact updates also normalize all line endings and ignore copyright changes. + +Delete the line-oriented TOML interpretation. Parse with the installed TOML library, serialize generated tables with it, and validate the complete proposed document before writing. A minimal comment-preserving strategy is to append a correctly serialized exact annotation to the document whose matching annotation must change, retaining existing bytes. Preserve newline style, last-match semantics, precedence, and sibling coverage; group updates to each document and write it once. + +### F05 — Header edits are not safe across supported syntax or modes + +`src/reconcile/mod.rs:171–178` preserves only `-->` and `*/`, although the detector recognizes many other block closers. An OCaml header `(* SPDX-License-Identifier: MIT *)` became an unterminated comment after replacement with Apache-2.0; exit 0. Additive editing of a one-line block can also mirror the wrong prefix or place another terminator outside the original block. + +`src/reconcile/insert.rs:45–56` does not implement PHP opening-tag placement despite `detect::leading_position` recognizing it. Applying to ` real.rs` before `real.rs` replaced the symlink with an ordinary annotated file and left the real file unchanged. Explicit outside-root paths are not rejected. The REUSE target requires ignoring symlinks, so the plan replaces the old symlink-following contract with a consistent exclusion policy. + +Use system Git for tracked enumeration and NUL-safe subsets, a single lexical normalization/containment path, and the REUSE exclusion rules. Separate declarative selection from the broader REUSE covered-file universe used by lint. + +### F08 — Lint is a policy-drift check masquerading as REUSE validation + +`src/cli/lint.rs:24–26` uses declaration drift plus missing texts to decide REUSE compliance. That omits copyright completeness and depends on having declaration rules even when actual metadata is valid. `src/engine.rs:97–102` inventories only the first effective expression; `:176–184` adds every configured rule ID even when the rule matches no file. `detect::HEAD_BYTES` limits metadata scans to 8 KiB. `inventory::is_complete` ignores unused/unrecognized license files and accepts empty text files by name. + +These independent fixtures were confirmed against the reference tool: + +| Fixture | Licet lint | REUSE 6.2.0 / spec 3.3 | +|---|---|---| +| MIT header, no copyright, real MIT text | Exit 0 | Exit 1: missing copyright | +| MIT and Apache-2.0 header lines, only MIT text | Exit 0, references only MIT | Exit 1: missing Apache-2.0 text | +| Apache-2.0 snippet after 10 KiB, only MIT text | Exit 0, references only MIT | Exit 1: missing Apache-2.0 text | +| `src/*` annotation and headerless `src/deep/a.rs` | Exit 0 | Exit 1: missing copyright and license | +| Valid license array and both license texts | Exit 1: missing header | Exit 0 | + +All fixture configs were themselves correctly annotated, and real bundled license texts were used. Reference runs had no read errors or invalid-license diagnostics. These disagreements therefore do not rely on artificial licensing of the fixture infrastructure. + +Additional source-confirmed gaps: ordinary `Copyright`/`©` notices are not recognized; malformed license tags are discarded instead of diagnosed; unclosed snippets do not cause a finding. A `license.toml` exclusion must not allow a covered file to disappear from a report claiming whole-project REUSE compliance. Inventory should validate useful structural properties of license files, but should not claim to establish legal equivalence of arbitrary prose to a license. + +### F09 — REUSE annotations are incomplete and precedence is lossy + +`src/reuse/oob.rs:36–37` accepts a license string but not the valid list form. Deserialization failure silently discards the entire file (`:88–92`). Required version/path and valid precedence are not enforced. Only root metadata is loaded (`:74–85`); nested REUSE files and their directory-relative precedence cannot work. Both REUSE.toml and deprecated DEP5 are accepted together despite their mutual-exclusion requirement. + +`Glob::new` in `oob.rs:179–183` lets `*` cross separators by default and interprets syntax not defined by REUSE. DEP5 and REUSE require distinct matching behavior. DEP5 continuation lines are lost. `src/detect/mod.rs:112–116` always combines copyright notices, even when precedence should suppress one source; copyright and license fallback need independent evaluation. + +Preserve a list of effective values and their originating documents, resolve the last matching table within each document, then resolve the directory hierarchy. Keep raw notices separately from effective values so preservation does not accidentally change meaning. The detailed parent/child matrix is in the plan. + +### F10 — Init invents a majority-based policy that can relicense exceptions + +`src/cli/init.rs:48–63` selects a majority default and majority license per extension, losing minority files with the same extension and exceptional extensionless files. With two MIT `.rs` files and one Apache-2.0 `.rs` file, init generated only default MIT. Following the suggested workflow would change the Apache file on the next apply. + +The generated configuration is only parsed for validity, never compared with observations. Existing `license.toml` is overwritten unconditionally at `:74`; `--config` is unused and `--from-reuse` changes reporting rather than behavior. No source mutation is necessary for init itself to lose valuable intent. + +Generate conservative exact rules first, compress only where every observed covered file keeps identical intent, and validate the generated projection. Do not assign licensing to previously unknown files through a guessed default. Create new output exclusively; require an explicit `--force` for replacing an existing configuration. + +### F11 — Copyright policies are parsed but not applied consistently + +`src/report/classify.rs:65–86` ignores copyright intent. `src/reconcile/mod.rs:51–58` returns a no-op when the license already matches; its existing-header branch never applies copyright policy. Exact REUSE rewrites also ignore the supplied copyright list. + +Confirmed: configured `copyright="add:2026 Bob"` plus an existing MIT/Alice header leaves Alice alone, reports compliance, and exits 0. Existing exact binary REUSE annotations retain only Alice under both `add:Bob` and `replace:Bob`, even when the license itself changes. + +Compare license intent and copyright intent independently. Preserve is the default; add is idempotent; replace changes only copyright metadata in the explicitly selected scope. Missing copyright remains a separate REUSE completeness problem when preserve has no existing notice to preserve. Never invent an author. + +### F12 — SPDX parsing validates with one parser and normalizes with another + +`src/spdx/mod.rs:77` discards the dependency AST and reparses `Expression::as_ref()`, which is the original input. `split_top_level` recognizes only literal space-surrounded operators. Configured tabs or newlines around OR validate successfully but compare unequal to ordinary-space headers. Three or more redundant surrounding parentheses also caused false drift in tested expressions. + +`spdx/mod.rs:159` uppercases a complete remaining suffix at every byte offset, giving quadratic work on a long valid LicenseRef. A one-file debug check took about 34 ms for a 1,000-character suffix, 424 ms for 4,000, and exceeded a four-second timeout for 16,000. This is a bounded diagnostic experiment, not a release benchmark. + +Use `spdx::Expression::iter()` and its `ExprNode::{Req, Op}` postfix representation to build the existing normalized representation. Use parsed requirements for inventory too. Delete handwritten whitespace, parenthesis, and WITH splitting. Preserve associative/commutative equality, exception attachment, and the distinction between AND and OR; full Boolean theorem proving remains unnecessary. + +### F13 — Errors are converted into apparently valid inputs + +Examples include file read errors becoming empty contents (`engine.rs:157`), walk errors being skipped (`walk/mod.rs:154`), malformed config becoming defaults (`cli/lint.rs:15–16`, `cli/add_license.rs:42–43`), invalid sidecars falling back to source content (`detect/mod.rs:100–106`), and failed Git status being treated as clean (`cli/apply.rs:284–294`). + +The implementation needs a consistent distinction among absence, invalid content, and I/O failure. Optional discovery may accept `NotFound`; an explicitly named missing configuration must be a usage error. An unreadable selected file must identify the path and reason, not become missing-header drift or successful coverage from an unexamined source. A failed clean-tree check must not authorize writes. + +### F14 — Comment-style mistakes are not rejected early + +`config/mod.rs:95–96` stores arbitrary named styles without resolving them. An unknown alias on `.rs` silently falls back to built-in Rust syntax. `inline_syntax` accepts an empty `line_prefix`; confirmed output was a bare SPDX line inside Rust with apply exit 0. Newlines/control characters in configurable tokens and copyright text also need validation before rendering. + +`comment/mod.rs:109` matches an `ExactPath` association by basename alone, so configuration for `a/foo` also applies to `b/foo`. Resolve exact paths before filenames and extensions, with consistent declaration-order rules. Reuse the existing registry; no new language plugin mechanism is warranted. + +### F15 — The preview and machine interfaces hide useful information + +Successful human apply reports skip compliant final files before showing their changes (`report/render.rs:15–16`). Sidecar changes are keyed by sidecar path while file states use asset path (`cli/apply.rs:178–182`, `report/mod.rs:135–141`), so their changes disappear from file entries. License-text writes are absent from the preview, as are actual before/after contents. A failed write can be rendered as “would apply,” conflating failure with dry-run. + +`check --explain` scans the full selected tree and returns 0 even when the path is absent; defaults are described as no rule matched. `init --format json --output 'a"b.toml'` emits invalid JSON because its path is interpolated directly (`cli/init.rs:77–81`). Curl prompts and progress use stdout even for JSON output (`cli/mod.rs:270–294`). Lint reports empty file lists and empty counts despite reporting file failures. + +A shared serializable report should expose file diagnostics and an independent list of planned/attempted writes, including source, sidecar, REUSE, config, and license-text destinations. Send diagnostics/progress to stderr; stdout in JSON mode must contain exactly one valid document. Keep ordinary output compact and make dry-run contain the proposed text/diff and unresolvable requirements. + +### F16 — Cache and unnecessary dependency work can be deleted + +`engine.rs:160` discards every cache lookup. Detection, classification, and content hashing still run, including under `--no-cache`. `walk/cache.rs:115` marks unchanged entries dirty, then flush rewrites the entire historical cache. Removed paths are not pruned. The existing keys omit sidecars and external metadata, so trusting the cached drift label would introduce new correctness bugs. + +Remove the inactive cache implementation; retain old CLI flags temporarily as documented deprecated no-ops if needed. Do not introduce a replacement until end-to-end measurements justify it. `gix` is used only for root discovery, although broad expensive features are enabled; system Git already supplies subset and dirty-tree operations. Consolidating Git access removes this dependency instead of duplicating engines. Remove unused direct dependencies only after searching their actual uses and running locked checks. + +### F17 — Green tests miss the scenarios that matter + +The test suite is a useful foundation: disposable Git fixtures, byte-stable reports, ordinary destructive/additive edits, sidecars, comment registry round trips, and ordinary precedence are already represented. Keep those assets. + +Weak points include optional external conformance even in CI (`tests/us5_reuse.rs:67`), substring-only init assertions, an offline test that only compares two outputs, and a partial-write fixture based on directory permissions that behaves differently under root. CRLF tests do not prove that no bare LF was introduced or that nonmetadata bytes survived. Existing target-header tests do not test invalid indexes, copyright-only blocks, or comments separated by blank lines. + +Add regressions at the actual public boundary for each finding, a compact table of REUSE differential fixtures, and full-buffer byte comparisons around edits. Assert command status, exact affected paths, unchanged failed-file contents, absence of outside-root writes, and post-apply behavior. Make the reference comparator mandatory in a dedicated CI job; failure to launch it must fail that job. A second independent review should assess semantic fixtures against the specification, not merely the implementation's outputs. + +### F18 — Publishing has source-visible blockers and premature side effects + +`.github/workflows/release.yml:12` grants contents write but not the `id-token: write` permission needed by its crates.io OIDC authentication action (`:121`). This permission requirement is documented by [GitHub](https://docs.github.com/en/actions/reference/security/oidc#workflow-permissions-for-the-requesting-the-oidc-token) and the [crates.io authentication action](https://github.com/rust-lang/crates-io-auth-action). Registry trust configuration remains unverified. + +The workflow publishes a public GitHub release before binary builds complete, and no tests gate tag releases. `prepare-release.yml` documents a GITHUB_TOKEN fallback that cannot trigger the tag workflow, then recommends manual release execution although `release.yml` has no manual trigger. + +Grant OIDC only to the publish job, validate/test the exact tagged commit, keep the release draft until artifacts succeed, and make the fallback operable or fail before creating a tag. Do not fix this by retagging or publishing during the improvement task. Re-query remote tags and versions before any future release; the mise/source version mismatch makes guessing a next version unsafe. + +### F19 — Metadata and documentation need narrower, truthful claims + +There are 14 embedded license texts, not the whole SPDX corpus. `build.rs:17–20` tolerates a missing bundle directory and skips directory-entry errors; `:43` reads a version environment variable without registering `rerun-if-env-changed`. The hardcoded text-bundle version does not necessarily describe the recognition list embedded by the spdx dependency. + +Mise selects nightly and installs Licet 0.2.2 while this checkout is 0.1.4. CI builds without `--locked`. Several comments still say gix handles subsets, every cache hit avoids work, all out-of-band values override headers, or new language support is purely data despite special insertion rules. Dead symmetry plumbing and hand-built success fallbacks make the implementation harder to assess. + +Document separate tool, recognition-list, and bundle versions; validate bundle inputs; align development checks with the MSRV/stable policy. Update contracts alongside behavior and remove false claims rather than preserving them in comments. + +## Performance measurements and recommendations + +The optimized existing Criterion benchmark completed with these point estimates: + +| Files | Existing “cold” engine benchmark | Existing “warm” engine benchmark | +|---:|---:|---:| +| 500 | 10.415 ms | 11.450 ms | +| 2,000 | 40.193 ms | 40.853 ms | + +These labels are not trustworthy comparisons of a real cold/warm CLI: “cold” disables cache; “warm” includes its own `.licet-bench-cache` file in selection and omits cache flush. They establish that the benchmark runs, not that its workloads are equivalent. + +A separate release-binary experiment used 10,000 committed tiny Rust files in 100 directories, default MIT, excluded config, and captured JSON output. Every full run asserted 10,000 compliant files; every staged run asserted one selected file. Five samples per mode gave: + +| End-to-end command mode | Median elapsed | +|---|---:| +| Full scan, `--no-cache` | 202.09 ms | +| Full scan, cache file removed before each run | 194.79 ms | +| Full scan, populated cache | 204.02 ms | +| One staged file after full scan, `--no-cache` | 14.77 ms | +| One staged file after full scan, populated cache | 17.19 ms | + +These are local wall-clock samples with a hot OS filesystem cache, not reference-runner guarantees or peak-memory measurements. The first no-cache sample took 1.196 seconds; the other full-scan samples ranged about 184–259 ms. Files were tiny and had no mixed OOB metadata. The cache shows no benefit on this workload, consistent with the traced code. Whole-file metadata scanning will change the workload; remeasure after correctness fixes. + +Priorities: delete inert caching and duplicate parsing, batch Git object reads and REUSE document updates, then measure representative small/large files and many annotations. Avoid an indexed rule engine or parallel walker until profiles show a meaningful bottleneck. Existing Rayon and the installed SPDX/TOML libraries are enough for the first repair pass. + +## Completion criteria + +The app is ready for review when every confirmed reproduction is a regression test, REUSE differential cases agree or have an explicitly documented policy-only difference, dry-run matches actual planned destinations, failure status is consistent across all output modes, and outside-root sentinels and nonmetadata bytes survive adversarial writes. Run all required checks on the final source and report platform coverage honestly. The plan ends at a reviewable implementation; it does not authorize a registry publication or tag mutation. diff --git a/docs/superpowers/2026-09-10-licet-improvements.md b/docs/superpowers/2026-09-10-licet-improvements.md new file mode 100644 index 0000000..afe0ba9 --- /dev/null +++ b/docs/superpowers/2026-09-10-licet-improvements.md @@ -0,0 +1,504 @@ +# Licet reliability and REUSE 3.3 implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Licet's declarative licensing operations safe, predictable, testable, and conformant with REUSE 3.3, with truthful reports and measured performance. + +**Architecture:** Keep the existing walk → detect/rules → classify → reconcile pipeline. Use system Git for repository snapshots, the installed SPDX parser for expressions, the installed TOML parser/serializer for metadata, and one safe file-replacement helper. Distinguish actual REUSE metadata from desired policy, compute a complete write plan before mutation, and remove the unused scan cache. + +**Tech Stack:** Rust edition 2024, MSRV 1.89, clap, serde, toml, spdx, globset, ignore, Rayon, tempfile, existing assert_cmd/insta fixtures. No HTTP/TLS/async-runtime dependency; optional downloads remain system curl. + +**Spec:** [Audit and evidence](../../2026-09-10-licet-audit.md), [supplied REUSE 3.3 specification](../../REUSE_Specification_v3.3.md), and the existing [behavioral spec](../../../specs/001-declarative-license-headers/spec.md), [CLI contract](../../../specs/001-declarative-license-headers/contracts/cli.md), [configuration contract](../../../specs/001-declarative-license-headers/contracts/config-schema.md), and [report schema](../../../specs/001-declarative-license-headers/contracts/report.schema.json). When older contracts contradict the REUSE target or the decisions below, update them as part of the owning task. + +## Global constraints + +- Edition 2024; minimum supported Rust version remains **1.89**. Do not raise it to fix local tooling. +- `license.toml` remains the sole declaration of desired policy. Existing metadata describes actual licensing and must retain its provenance. +- Preserve copyright by default. `add:` and explicit `replace:` must work consistently. Never invent a copyright holder or placeholder license text. +- Preserve all nonmetadata bytes and existing file permissions during replacement; preserve LF/CRLF, BOM, and required language preambles. +- Never write through a symlink, escape the project through a metadata-derived path, or delete an existing destination after a failed download. +- Read-only commands and dry runs perform no writes, including caches, temporary project files, license downloads, or metadata updates. +- Network is opt-in through existing curl capability. JSON stdout is exactly one serialized document; progress and prompts go to stderr. +- Keep existing cross-platform CI and binary targets. Platform-specific safety tests may use `#[cfg(unix)]` or Windows equivalents, with explicit coverage limits. +- Do not add a replacement cache, generic filesystem/provider interfaces, plugin systems, a database, or a license-policy DSL. +- Reuse installed dependencies. Promote the already installed `tempfile` to a normal dependency for production atomic writes; remove `gix` once system Git owns discovery. No other new runtime dependency is needed for this plan. +- Existing 0.1.4 source and mise's 0.2.2 installation differ. Do not guess a next package version, tag, publish, or mutate remote releases while implementing. Use the source-built binary. +- Source snapshot has no Git metadata. An executor must locate the actual repository or initialize an isolated development copy before making commits; never fabricate a reviewed commit SHA for this snapshot. + +## Behavior decisions — implement these consistently + +These are product decisions for this repair, not unresolved questions for the executor. + +1. **Command meaning.** `check` validates desired policy over its selected files and requires the license texts referenced by those files. `lint` validates actual REUSE 3.3 metadata over every REUSE Covered File, independently of whether a declaration rule exists. A project can satisfy policy but still lack copyright required by REUSE when its policy is `preserve`; state that distinction plainly. `lint` neither parses auto-discovered policy configuration nor obeys its exclusions. `apply` reconciles declared policy and required texts, then reports the resulting policy status. Users run `lint` for complete REUSE validation. +2. **Coverage.** In a Git repository, default `check`/`apply` coverage is tracked regular files, honoring explicit declaration exclusions but not dropping tracked files because a later ignore rule matches them. `lint` covers tracked plus nonignored untracked files, minus REUSE-specified ignored files. In a nonrepository directory, retain a nonignored filesystem walk; `apply` there requires `--allow-dirty` because no Git undo guarantee exists. Git launch/status errors never mean “clean.” +3. **Snapshots.** `check --staged` evaluates index blobs, including applicable staged metadata/configuration/license texts. `check --changed REV` evaluates current working-tree bytes for paths changed relative to the validated commit. `apply --staged` uses the staged **path set** but edits working-tree files, advertises that fact, obeys the dirty-tree guard, and never stages its edits. Freeze the chosen path set for post-apply verification. Any changed licensing metadata that can affect other files expands a staged/changed check to the relevant snapshot's complete covered set; conservative full expansion is acceptable and must be reported. +4. **Paths.** Explicit file arguments are relative to invocation cwd; declaration selectors and metadata paths are relative to their documented base. Normalize `.`/`..` lexically without changing file identity. Reject paths outside the root and nonregular input paths. Symlinks are ignored consistently, including explicit selection, as required by REUSE; remove the old canonical-target dedup/follow contract. Keep OS paths as OS paths until presentation. Do not replace Unix literal backslashes with separators. +5. **Metadata.** Keep all effective file license expressions and copyright notices, separately from raw editable spans. Multiple associated license expressions apply together: compare their canonical AND-combination with declarative intent. Snippet expressions contribute to license inventory but never satisfy file policy. A malformed tag produces a diagnostic instead of disappearing. Sidecars replace source-file licensing information; malformed sidecars are errors, not permission to fall back silently. +6. **Apply modes.** Destructive mode replaces license values in the selected header scope, preserves other headers and snippets, and reports any remaining policy drift. `--target-header N` is zero-based and must be in range for every affected file; a matching license elsewhere does not defeat an explicit target. Additive mode preserves every existing license expression for in-file, sidecar, and REUSE destinations; if combined actual licensing still differs from intent, exit 1 with a residual-drift diagnostic. Do not call all multi-license combinations intrinsically contradictory. Existing tests expecting any matching line to prove complete compliance must change. +7. **REUSE writes.** Fix the effective source, not merely the source file's syntax. For a REUSE document, retain existing bytes and append serialized exact-path annotations when an update is required; put them in the document whose matching annotation must be superseded. Preserve precedence, unknown keys relevant to the superseded table, and effective copyright. Batch updates to one document. If multiple aggregate/DEP5 sources cannot be safely reconciled without changing sibling meaning, return an actionable unfixable result before writes instead of pretending to fix them. +8. **Outcomes.** Exit 0 means the command's success condition is true. Exit 1 means valid input but remaining policy/compliance violations, unfixable requirements, incomplete validation, or failed writes with no successful changes. A completed additive or targeted edit with residual policy drift is exit 1, even though its requested writes succeeded. Exit 2 means invocation, config, malformed metadata, or snapshot preparation failed before any write. Exit 3 means one or more files were actually changed and an operational error subsequently occurred (including a failed replacement, durability check, or final scan). Once a write commits, later operational errors are partial results rather than unrelated exit 2 errors. Dry-run returns 0 only when fully determined local replacements satisfy policy and text requirements; unverified required downloads or other unresolved requirements yield exit 1, and invalid input yields 2. +9. **Reports.** Introduce report schema **version 2** for independent diagnostics and write records. Do not silently put incompatible shapes in v1. Retain existing human/json format names and command names. Include before and projected/final pass states so dry-run success cannot be confused with current compliance. Include `summary.complete`; unsupported encoding or an unread dependency makes validation incomplete, never a proven REUSE violation or a pass. +10. **Init.** Conservative projection preservation outranks fewer rules. Emit exact rules first; compress only provably uniform groups. Previously unknown files remain unknown. Output defaults to the discovered root's `license.toml`; explicit `--output` overrides `--config`, which is treated as the destination for init. Keep `--from-reuse` as a documented compatibility alias for the existing inspection behavior. Refuse overwrite unless `--force`; explicit output outside the project authorizes only that particular config destination, using its parent as the writer's allowed root. +11. **Encoding and filename compatibility.** The repair guarantees UTF-8 metadata and sources; binary assets remain coverable through readable sidecars/OOB metadata. REUSE only recommends UTF-8, so undecodable non-UTF-8 metadata is reported as `unsupported_encoding`, incomplete validation, exit 1, rather than a normative licensing violation. Do not discard it, rewrite it, or call the project compliant. Document this implementation limit. Existing extensionless license texts remain recognizable to materialization/policy operations, but strict REUSE 3.3 lint reports `missing_license_extension`; the specification requires a filename extension even though the reference tool accepts some extensionless names. Record this deliberate normative/reference difference in differential fixtures. + +## Execution order and review ownership + +There are twelve independently reviewable tasks. Complete tasks 1–9 before calling the app reliable; tasks 10–12 complete the requested performance, testing, and delivery improvements. + +```text +1 safe writes ────────────────┐ +2 file selection ──┐ │ +3 detection/SPDX ──┼─> 4 OOB ─┼─> 5 classification/inventory + │ │ │ + └──────────┴──────────────> 6 reconciliation + │ + v + 7 outcomes/preview + │ + 8 init, 9 CLI/config + │ + 10 performance + │ + 11 CI evidence + │ + 12 release/docs +``` + +Use delegated bounded implementation or review where it is independent. Assign one agent ownership of each touched module at a time; tell workers they are not alone and must not revert others' edits. Tasks 2–5 share domain/engine types: land their agreed shape before parallelizing dependent work. Do not ask several agents to redesign metadata independently while implementation is underway. A separate reviewer should reject a task when acceptance is only demonstrated by the old tests. + +For each task: add the specified failing regression, run it to observe the original failure, implement the minimum change, run the focused suite, review the diff, update the relevant contract, and commit in the isolated actual repository. Do not “fix” a regression by weakening its expected behavior. Commands below assume the repository root. + +## Task 1 — Contain and preserve every filesystem write + +**Findings:** F01, F02, F04, F13. **Depends on:** none. + +**Files:** modify `src/reuse/mod.rs`, `src/reuse/inventory.rs`, `src/cli/init.rs`, `src/cli/apply.rs`, `src/reuse/oob.rs`, `Cargo.toml`, `Cargo.lock`; extend `tests/safety.rs`, `tests/us5_add_license.rs`. + +**Interface:** replace the text-only writer with one byte-safe helper; all mutation callers must supply their allowed root, relative path, and expected old contents. `None` means the destination must not exist; an empty existing file is `Some(&[])`. + +```rust +pub fn atomic_write( + root: &std::path::Path, + relative: &std::path::Path, + expected: Option<&[u8]>, + replacement: &[u8], +) -> Result<(), WriteError>; + +#[derive(Debug, thiserror::Error)] +#[error("{source}")] +pub struct WriteError { + pub source: std::io::Error, + pub replacement_completed: bool, +} +``` + +- [ ] Add this regression to `tests/safety.rs` (existing `mod common`/`Fixture` setup applies), and run `cargo test --locked --test safety apply_does_not_follow_predictable_temp_symlink`. + +```rust +#[cfg(unix)] +#[test] +fn apply_does_not_follow_predictable_temp_symlink() { + use std::os::unix::fs::symlink; + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n") + .write("a.rs", "fn a() {}\n"); + let outside = tempfile::tempdir().unwrap(); + let sentinel = outside.path().join("sentinel"); + std::fs::write(&sentinel, b"KEEP").unwrap(); + symlink(&sentinel, f.path().join(".a.rs.licet.tmp")).unwrap(); + let out = f.licet() + .args(["apply", "--allow-dirty", "--files", "a.rs"]) + .output().unwrap(); + assert!(out.status.success(), "{:?}", out); + assert_eq!(std::fs::read(&sentinel).unwrap(), b"KEEP"); + assert!(!std::fs::symlink_metadata(f.path().join("a.rs")) + .unwrap().file_type().is_symlink()); +} +``` + +- [ ] Add table-driven writer tests: preserve modes `0600`, `0700`, `0755`; preserve existing predictable temp file contents; reject `../outside`, absolute relative-path inputs, symlink destination, symlink parent, directory/FIFO destination; a failed replacement keeps the original bytes; expected-content mismatch aborts; two independent destinations do not collide. Guard Unix permission/FIFO cases appropriately and add Windows reparse-path coverage in CI. +- [ ] Implement destination validation **inside** the shared writer. Canonicalize the allowed root, walk the relative path components, reject escape components and symlink/reparse ancestors, and distinguish missing final destination from other metadata errors. Do not use `exists()` to collapse permission/read failures into absence. +- [ ] Create the temporary file with `tempfile::NamedTempFile::new_in(parent)`. Write/flush contents, set existing destination permissions before replacement, sync the temporary file, recheck expected destination bytes/type, and persist. Use no-clobber persistence for creation. Clean up only the owned temp on errors; do not unlink a destination to work around Windows rename behavior. Preserve directory durability where supported and propagate sync errors. Errors before persistence set `replacement_completed=false`; errors after persistence set it true so reporting counts a committed write even if its durability check failed. Add a library-level test seam for a forced post-persist sync failure and assert both resulting disk bytes and this flag; no production CLI test switches. +- [ ] Validate materialization IDs against known SPDX licenses or standard exceptions; allow syntactically valid `LicenseRef-*` only to report a required local text, never for downloading. Canonicalize standard ID spelling, reject separators/absolute paths/control bytes, and reject a compound expression passed as one `add-license` ID. Validate the whole requested list before creating any directory. +- [ ] Change curl to write to owned temporary storage, with `--disable` first (ignore user curl configuration), `--fail --silent --show-error --location`, HTTPS-only initial/redirect protocols, `--connect-timeout 10`, `--max-time 60`, and a 4 MiB maximum text size. Preserve the explicit `--allow-curl`/TTY consent path. After nonempty UTF-8 text validation, install through the shared writer; failure never deletes the final destination. Pin download URLs to the documented corpus release when supplied by the bundle metadata, rather than a floating branch. +- [ ] Put fake curl on PATH in tests. `add-license ../victim --allow-curl` must exit 2 before invoking fake curl and retain `victim.txt`. Bundled MIT must never invoke curl. A valid unbundled standard ID with fake curl success creates one text; exit 22, empty result, oversized result, and launch failure create none and preserve existing files. JSON mode must remain parseable. +- [ ] Replace all `read_to_string(...).unwrap_or_default()` on mutation destinations with a `NotFound`-only absence branch. Route source, sidecar, metadata, init, and license-text writes through the helper. The old cache will be removed in task 10; until then it must never use the unsafe writer. +- [ ] Run `cargo test --locked --test safety --test us5_add_license --test us5_sidecars`, then commit `fix: contain atomic writes and license downloads`. + +**Review ceiling:** The portable helper protects against pre-existing malicious paths and detects changes observed between planning and replacement. It is not an OS-level compare-and-swap against a hostile process concurrently replacing ancestors. Do not claim stronger race guarantees. If same-process/multi-process tests expose an actual remaining attack path, use handle-relative platform operations for that path; do not add a speculative filesystem framework. + +## Task 2 — Make file selection and snapshot reads correct + +**Findings:** F06, F07, F13. **Depends on:** task 1 containment policy. + +**Files:** modify `src/walk/mod.rs`, `src/engine.rs`, `src/cli/mod.rs`, `src/cli/check.rs`, `src/cli/apply.rs`, `src/cli/lint.rs`, `src/domain.rs`; add `src/walk/git.rs` only to hold the concrete Git command/snapshot code; extend `tests/us4_enforce.rs`, `tests/safety.rs`, `tests/common/mod.rs`. + +**Interfaces:** use a small content-source enum, not a provider trait. Carry the same source through all dependent reads. `Discovered` retains OS-native relative paths. Store index entries by path and object ID once per scan. + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContentSource { Worktree, Index } + +// In walk/git.rs; return checked raw output and retain stderr in errors. +pub fn git_output(root: &std::path::Path, args: &[&std::ffi::OsStr]) + -> crate::Result>; +``` + +- [ ] Extend `Fixture::new()` to disable inherited commit signing and repository hooks locally. Set `commit.gpgsign=false`, `tag.gpgsign=false`, and `core.hooksPath` to a newly created empty fixture directory; keep that directory out of fixture commits. This prevents the audit's signing-agent failure from recurring. Add a helper for checked Git commands and use it in new fixtures. +- [ ] Add staged-snapshot regression in `tests/us4_enforce.rs`: + +```rust +#[test] +fn staged_check_reads_index_bytes() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("f.rs", "// SPDX-License-Identifier: MIT\n") + .commit("base"); + f.write("f.rs", "// SPDX-License-Identifier: Apache-2.0\n").stage_all(); + f.write("f.rs", "// SPDX-License-Identifier: MIT\n"); + let out = f.licet().args(["check", "--staged", "--format", "json"]) + .output().unwrap(); + assert_eq!(out.status.code(), Some(1)); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!(report["files"].as_array().unwrap().iter().any(|entry| + entry["path"] == "f.rs" && entry["actual"] == "Apache-2.0")); +} +``` + +- [ ] Add reverse staged/worktree content case, staged deletion/rename/addition on unborn HEAD, unresolved merge index, staged sidecar with different working-tree sidecar, metadata-only staged change affecting unchanged source, and staged license-text deletion. Assert no index mutation. Explicit custom `--config` for index checking must exist in that same snapshot inside the root; otherwise exit 2 instead of silently reading the working copy. +- [ ] Use checked system Git commands: root with `rev-parse --show-toplevel`, Git metadata directory with `rev-parse --absolute-git-dir`, tracked entries with `ls-files --stage -z`, subsets with `diff --name-only -z --diff-filter=d`, and validate REV as a commit before passing its object ID to diff. Use `--`/end-of-options as appropriate; do not interpolate user revisions into shell text. Distinguish a nonrepository from Git missing or failing; no arbitrary Git error may authorize mutation. +- [ ] Decode NUL-delimited filenames losslessly. On Unix use `OsStringExt::from_vec`; preserve bytes through lookup and writes. Use escaped display plus an optional byte-array path field in JSON for non-UTF-8 Unix paths. On a platform unable to represent a path, produce an explicit failure instead of a lossy replacement. Spaces, quotes, Unicode, tab, newline, and literal Unix backslash must work. +- [ ] Read index blobs with one `git cat-file --batch` process addressed by object IDs from the index, parsing the declared blob length exactly. Do not send filenames through newline framing and do not spawn once per file. Read file, sidecar, all applicable metadata, config, and LICENSES through the same selected snapshot. Gitlinks and symlink index modes are excluded from covered content. +- [ ] Implement a common normalized path pipeline; test `src/x.rs`, `./src/x.rs`, and `src/../src/x.rs` from root and subdirectory. Reject outside-root file arguments before scanning. Skip symlinks identically regardless of order in an explicit list; do not canonical-dedup different ordinary paths. +- [ ] Implement the REUSE ignore matrix: root LICENSES text files, allowed COPYING/LICENSE/LICENCE variants at any depth, VCS internals, VCS-ignored untracked content, submodules, Meson subprojects as separate projects, all applicable REUSE.toml files, root `.reuse`, symlinks, zero-byte files, and SPDX document formats. Nonempty `data.empty` is not exempt by suffix. Nested unrelated `LICENSES` directories are not automatically a project-wide exemption. Test `sub/LICENSE-MIT`, `sub/COPYING.GPL`, and `sub/LICENCE.md` as ignored, but `sub/LICENSES/a.rs` as covered. Sidecars are checked through their companion; diagnose orphan/malformed sidecars according to the reference fixture behavior. +- [ ] Use tracked entries for default policy operations and tracked plus `ls-files --others --exclude-standard -z` for Git-backed lint. In the nonrepository fallback, retain `ignore` traversal and surface traversal/read errors. Do not apply arbitrary declaration exclusions to lint. Test a compliant repo with no license.toml and an unlicensed file hidden only by a declaration exclusion. +- [ ] Run `cargo test --locked --test us4_enforce --test safety --test determinism`. Review linked-worktree, metadata-only subset, and non-UTF-8 cases independently; commit `fix: evaluate licensing against consistent Git snapshots`. + +## Task 3 — Use parsed SPDX expressions and complete metadata scans + +**Findings:** F08, F12, F13. **Depends on:** task 2 read-source interface. + +**Files:** modify `src/spdx/mod.rs`, `src/detect/mod.rs`, `src/domain.rs`, `src/engine.rs`; extend `tests/us5_snippets.rs`, `tests/us1_check_drift.rs`, and existing unit tests. + +**Interfaces:** retain `parse_canonical` and `expressions_equal` for callers, but require parse errors to remain diagnostics. Add `expression_ids(expr: &str) -> Result, String>` using the dependency requirements. Extend `ActualLicenseState` with `effective_licenses: Vec` and `diagnostics`; keep raw `headers` for later surgical edits. Keep `detected_license` only as the canonical combined presentation value, not the inventory source. + +- [ ] Add and run this unit regression: + +```rust +#[test] +fn canonicalization_uses_parsed_structure() { + for expr in [ + "MIT OR Apache-2.0", + "MIT\tOR\tApache-2.0", + "MIT\nOR\nApache-2.0", + "(((MIT OR Apache-2.0)))", + "Apache-2.0 OR MIT", + ] { + assert!(expressions_equal(expr, "MIT OR Apache-2.0"), "{expr:?}"); + } + assert!(!expressions_equal("MIT AND Apache-2.0", "MIT OR Apache-2.0")); +} +``` + +- [ ] Replace string reparsing with the already available postfix AST. Keep the existing `Node` rendering initially; populate it as follows, then delete `parse_node`, `split_top_level`, `strip_outer_parens`, `normalize_leaf`, and `split_with` when their callers disappear: + +```rust +use spdx::expression::{ExprNode, Operator}; +fn canonicalize(expr: &spdx::Expression) -> String { + let mut stack = Vec::new(); + for item in expr.iter() { + match item { + ExprNode::Req(req) => stack.push(Node::Leaf(req.req.to_string())), + ExprNode::Op(op) => { + let right = stack.pop().expect("validated expression: right operand"); + let left = stack.pop().expect("validated expression: left operand"); + stack.push(match op { + Operator::And => Node::And(vec![left, right]), + Operator::Or => Node::Or(vec![left, right]), + }); + } + } + } + render(&stack.pop().expect("validated nonempty expression")) +} +``` + +- [ ] Check case/canonical ID policy against the existing public tests. Use the dependency's lax parsing/canonical IDs only where needed for the promised standard-ID case tolerance; preserve custom LicenseRef case. Do not retain an invalid-raw-string equality fallback that can turn malformed metadata into success. REUSE 3.3 rejects nonstandard exception identifiers even if a newer SPDX parser accepts custom additions. Add explicit tests for WITH, standard exceptions, `+`, LicenseRef, malformed values, and AND/OR grouping. Use `requirements()` for every license and exception reference, with reference fixtures for legacy `GPL-2.0+` filename normalization. +- [ ] Remove the 8 KiB semantic cutoff. Scan text metadata to EOF using `BufRead::read_until(b'\n', ...)`, preserving parser state across chunks and absolute byte offsets. Keep memory bounded to active input and retained metadata; a binary containing non-UTF-8 bytes is not an I/O failure. If sidecar or overriding metadata makes source bytes irrelevant, avoid unnecessarily decoding the binary asset. Apply still loads a target's full bytes when preparing an edit. +- [ ] Refactor the existing marker logic into a reusable per-line parser state, shared by streaming reads and parsing in-memory replacement content. Track ignored regions, snippet begin/end, file metadata, and byte spans. Tags within snippets do not become file headers. Diagnose invalid license values, unclosed/nested/mismatched snippets, unsupported required metadata encoding, and incomplete reads. Treat unclosed ignore blocks according to the supplied spec/reference fixture; never quietly discard an unrelated I/O error. Add Latin-1 source/sidecar/license-text fixtures containing a non-ASCII holder: required undecodable metadata produces `unsupported_encoding` and `complete=false`; a non-UTF-8 asset with complete UTF-8 sidecar/OOB coverage can still validate. Do not assert that Latin-1 is prohibited by REUSE. +- [ ] Recognize tagged and conventional `Copyright`/`©` notices; retain original raw text and location. Validate nonblank holder information without requiring a year or email. Do not mistake a source-code string example for an editable comment; detection may find a tag anywhere for REUSE parity, but write spans need a validated comment context. Ignore-block examples must be tested. +- [ ] Add CLI fixtures: valid header and late snippet at 10 KiB and 1 MiB; marker crossing the old 8 KiB boundary; UTF-8 sequence crossing a chunk boundary; sidecar >8 KiB; binary with valid sidecar; malformed sidecar over valid source; invalid tag alongside valid tag; copyright-only header followed by blank line and license; a 16 KiB LicenseRef processed without the old quadratic delay. No hard microsecond assertion in ordinary unit tests. +- [ ] Run `cargo test --locked spdx::` and `cargo test --locked --test us5_snippets --test us1_check_drift`; commit `fix: parse complete licensing metadata with the SPDX AST`. + +## Task 4 — Resolve complete REUSE metadata with provenance + +**Findings:** F08, F09, F13. **Depends on:** tasks 2 and 3. + +**Files:** modify `src/reuse/oob.rs`, `src/domain.rs`, `src/detect/mod.rs`, `src/engine.rs`; extend `tests/us5_reuse.rs`, `tests/safety.rs`, `tests/us5_sidecars.rs`. + +**Interfaces:** make `OutOfBand::load` fallible and supply the current content source. Replace single-value `OutOfBandEntry.license` with a list. Retain each selected table's metadata path, directory base, table index, precedence, licenses, and copyrights. Keep a compact concrete `MetadataOrigin` record in `domain.rs`; no virtual-source hierarchy. + +```rust +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +#[serde(untagged)] +enum StringList { One(String), Many(Vec) } + +#[derive(Debug, serde::Deserialize)] +struct ReuseToml { + version: u32, + #[serde(default)] + annotations: Vec, +} +// path is required StringList; licensing and copyright fields are optional StringList. +// Preserve/allow unknown REUSE keys; the REUSE schema explicitly permits extension keys. +``` + +- [ ] Add a valid license-array fixture with both real license texts and a metadata-covered binary. Assert all expressions appear in effective metadata and inventory; a valid array in one table must not discard other tables. Make malformed document, version other than 1, missing path, invalid precedence, invalid expression, and REUSE-plus-DEP5 coexistence fail before writes with document path and parse location. +- [ ] Discover applicable nonignored REUSE files at every directory depth using the same snapshot. A nested file's paths are relative to that metadata file, may not escape its directory, and can cover only its descendants. Retain last-match selection **within each document** before hierarchy resolution. +- [ ] Implement a separate REUSE pattern compiler: `*` cannot cross `/`; `**` and `**/` can; only the specified asterisk/backslash escapes are special. Quote `?`, brackets, and braces before feeding globset, or translate the small REUSE grammar into globset's escaped syntax; use `GlobBuilder::literal_separator(true)`. Do not reuse it unchanged for DEP5. Add literal special-character filenames and escape cases. +- [ ] Resolve copyright and license independently. This matrix is mandatory, including cases where a table provides only one field: + +| Root/parent metadata | Child metadata | File/sidecar | Effective field | +|---|---|---|---| +| closest A | absent | B | B | +| closest A | closest B | absent | B | +| closest A | override B | C | B | +| aggregate A | closest B | C | A plus C | +| aggregate A | closest B | absent | A plus B | +| override A | override B | C | A | +| closest A | aggregate B | C | B plus C | +| absent | absent | sidecar B, source C | B | + +- [ ] Determine the rootmost matching override barrier first: it suppresses all closer tables and file/sidecar licensing information, even if the override table omits one field. Then resolve license and copyright independently over the allowed contributors; an absent field contributes nothing, rather than reopening suppressed sources. Add root copyright-only override plus complete file (override copyright, missing license), root license-only override plus complete file (override license, missing copyright), parent aggregate plus child override (parent aggregate retained), and parent override plus child aggregate (child suppressed). Keep raw suppressed notices available for preservation but exclude them from effective values and inventory. Include all contributing source records in JSON explanation. +- [ ] Implement DEP5 paragraph continuation/unfolding for Files, Copyright, and License; aggregate it with file-level metadata. Preserve unsupported extra fields without interpreting them. Never emit a new REUSE document alongside existing DEP5. Existing DEP5 mutation remains manual with precise affected paths and a nonzero result. +- [ ] Run the matrix through the pinned reference comparator as well as internal assertions; `licet lint` and `reuse lint --json` must agree on coverage/reference sets. Run `cargo test --locked --test us5_reuse --test us5_sidecars --test safety`; commit `fix: resolve REUSE annotations and precedence without losing values`. + +## Task 5 — Separate policy drift, REUSE validation, and license inventory + +**Findings:** F08, F11, F13. **Depends on:** tasks 3 and 4. + +**Files:** modify `src/report/classify.rs`, `src/domain.rs`, `src/reuse/inventory.rs`, `src/engine.rs`, `src/cli/check.rs`, `src/cli/lint.rs`, `src/cli/add_license.rs`, `src/rules/mod.rs`; extend relevant unit tests and `tests/us5_reuse.rs`, `tests/us5_add_license.rs`. + +**Interfaces:** keep separate `actual_referenced_ids` and `desired_referenced_ids` in `ScanResult`. Inventory computation becomes fallible and accepts actual reference IDs explicitly. File diagnostics carry stable codes such as `missing_copyright`, `invalid_license`, `read_error`, `unclosed_snippet`; do not encode them as fabricated `` licenses. Add `CopyrightMismatch` policy drift with its own count to the v2 schema; if license and copyright both differ, use `WrongLicense` as the primary drift and retain the copyright mismatch as a diagnostic. + +- [ ] Add the missing-copyright false-pass regression below to `tests/us5_reuse.rs`: + +```rust +#[test] +fn lint_requires_copyright_independently_of_license_intent() { + let f = Fixture::new(); + f.config("# SPDX-License-Identifier: MIT\n# SPDX-FileCopyrightText: 2026 Test\n[default]\nlicense=\"MIT\"\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\n") + .write("LICENSES/MIT.txt", licet::spdx::bundled_text("MIT").unwrap()); + let out = f.licet().args(["lint", "--format", "json"]).output().unwrap(); + assert_eq!(out.status.code(), Some(1)); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(report["summary"]["pass"], false); + assert!(out.stdout.windows(b"missing_copyright".len()) + .any(|bytes| bytes == b"missing_copyright")); +} +``` + +- [ ] Policy equality uses all effective license expressions, joined as an AND expression with necessary parentheses, parsed/canonicalized once. A matching MIT candidate must not hide an additional Apache license. Preserve syntactically valid multiple expressions as valid REUSE metadata while distinguishing declaration mismatch. Update additive/target-header tests to the explicit mode policy above. +- [ ] Compare copyright policy separately: preserve accepts existing notices without creating one; add requires existing notices plus the requested normalized notice; replace requires the explicit requested notice in the chosen effective scope. Compare duplicate rules using the entire normalized intent, including copyright policy. Equal specificity and different intent is a conflict; identical intent resolves to earliest declaration order. Document the rule instead of simultaneously promising last-order overrides and conflicts. +- [ ] `lint` succeeds without `license.toml` when actual metadata is complete. It does not parse auto-discovered policy content; that file is simply another Covered File whose licensing metadata is checked. Retain lint's explicit `--config` flag for compatibility: explicitly supplied missing/malformed config is usage error 2, and an existing valid explicit config is accepted with a deprecation notice but does not change REUSE evaluation. Change this flag to `Option` so omitted and explicit inputs can be distinguished. Test correctly annotated but malformed auto-discovered policy TOML as REUSE-compliant. `check` continues to require its config. `add-license --all` uses the union of desired and actual references and must not swallow config errors. +- [ ] Inventory **every effective file expression and snippet expression**, including exceptions. Exclude suppressed values and unused config rules from REUSE actual inventory. In policy `check`, require texts for the selected actual/desired scope as documented. `apply` supplies required texts for its projected selected state, not stale replaced licenses or unrelated unused rules. Never delete unused texts automatically. +- [ ] Inventory validates regular contained text files, nonempty readable text, recognized SPDX/LicenseRef naming, duplicate IDs under multiple filenames, and unused/unrecognized entries. Project-wide unused/unrecognized findings affect only lint's full-reference evaluation, never a selected-file policy check. Test MIT `a.rs` and Apache `b.rs` with both texts: `check --files a.rs` and lint pass; deleting `b.rs` makes lint alone flag the unused Apache text. Use the pinned tool to characterize plaintext suffixes and legacy `+` naming. Support `.txt`/`.md`; recognize extensionless IDs for existing materialization compatibility, but lint reports their missing extension per decision 11. Explicitly test `LicenseRef-Acme-1.0.txt` versus `LicenseRef-Acme-1.0`, preserving the custom identifier's dotted version. Report unsupported encoding as incomplete validation, separately from empty/missing text or malformed names. Do not claim to prove legal correctness of arbitrary file contents. +- [ ] Test multiple header expressions, aggregate expressions, excluded snippet text, late snippets, missing exception, empty/invalid/binary license text, unused text, unknown file in LICENSES, no-config valid project, unlicensed file excluded only in declaration config, and unreachable desired rule. Check result codes **and** precise reference sets. +- [ ] Run `cargo test --locked --test us5_reuse --test us5_add_license --test us1_check_drift`; commit `fix: distinguish declared policy from REUSE compliance`. + +## Task 6 — Reconcile metadata without corrupting sources or changing the wrong scope + +**Findings:** F04, F05, F11, F14. **Depends on:** tasks 1–5. + +**Files:** modify `src/reconcile/mod.rs`, `src/reconcile/insert.rs`, `src/comment/mod.rs`, `src/comment/comment_style.rs`, `src/detect/mod.rs`, `src/reuse/oob.rs`, `src/cli/apply.rs`; extend `tests/us2_apply.rs`, `tests/us3_comment_style.rs`, `tests/us5_sidecars.rs`. + +**Interfaces:** `plan_file` becomes fallible. Carry comment context and exact SPDX value byte spans from detection, so replacement does not reparse arbitrary trailing code. Return planned bytes plus explicit mode/target/change reason. Sidecars and REUSE edits consume the same computed desired license/copyright values. + +- [ ] Add table-driven preservation cases using this complete assertion pattern in reconciliation unit tests: + +```rust +#[test] +fn ocaml_license_replacement_preserves_block_closer() { + let old = "(* SPDX-License-Identifier: MIT *)\nlet x = 1\n"; + let actual = crate::detect::detect( + std::path::Path::new("a.ml"), old.as_bytes(), None, + &crate::reuse::oob::OutOfBand::default(), + ); + let intent = crate::domain::LicenseIntent { + license_expression: "Apache-2.0".into(), + copyright_policy: crate::domain::CopyrightPolicy::Preserve, + }; + let plan = plan_file(old, &actual, &intent, + &crate::domain::CommentSyntax::block_only("(*", "*)", ""), + crate::domain::ChangeMode::Destructive, None).unwrap(); + assert_eq!(plan.new_content.as_deref(), + Some("(* SPDX-License-Identifier: Apache-2.0 *)\nlet x = 1\n")); +} +``` + +- [ ] Replace only validated metadata spans, applying edits in descending byte-offset order. Preserve opener/closer/trailing source exactly. Use the registry for both reading editable context and rendering; delete the independent two-terminator list. Additive mode inserts a valid separate comment/metadata line, never duplicates a terminator into executable text. A tag detected in ordinary program text without a safe comment span must not be rewritten; report an actionable unfixable location or use an explicitly chosen sidecar strategy. +- [ ] Distinguish copyright-only and license-bearing blocks. Without `--target-header`, target the first file-level license block; if none exists, insert a license in a safe header context while preserving existing notices. With an explicit index, validate it without clamping. Apply copyright intent even when the license already matches. Never edit snippets as file-level licenses. Preserve unrelated headers and report residual differences. +- [ ] Consolidate insertion-position logic. Support BOM immediately followed by shebang, shebang plus Python encoding declaration, XML declaration on its own line or before same-line body, PHP opening tag, Cabal required first line, and the existing TeX/BibTeX metadata preambles. For BOM-only prefix, do not insert a newline between BOM and a required shebang. Add exact-byte tests for LF, CRLF, no trailing newline, Unicode, multiline and single-line block comments. Where a language interpreter/compiler is installed, smoke-check representative generated files; deterministic byte assertions remain mandatory. +- [ ] Choose the write destination from effective metadata provenance **before** choosing source comment syntax. A root overriding REUSE annotation remains authoritative even when the source is Rust. Existing sidecars remain the target; sidecar additive mode preserves old identifiers and notices. Unsupported conflicting DEP5/aggregate layouts yield an unfixable plan entry instead of ineffective source edits. +- [ ] Delete `parse_blocks`, `quoted_values`, and handwritten `toml_string` in `reuse/oob.rs`. Parse existing metadata once, derive required exact exceptions, serialize an append-only patch with `toml::to_string`, and parse the complete proposed result before committing. Serialize copyrights as one string/list field, never repeated keys. Escape a literal path according to REUSE pattern grammar before TOML serialization so filenames containing `*` or backslashes do not become new globs. Retain original unknown fields where needed to preserve annotation meaning. Reuse the original newline convention for appended content. +- [ ] Append to the winning document at its own relative base, retaining/setting the precedence needed to supersede only the chosen path. Group all planned exceptions by destination so 1,000 assets produce one metadata write. Repeated apply with identical intent must add no annotation and perform no writes. Changing the license/copyright can append one replacement exact exception; no compaction/reformatting of hand-authored tables is required in this repair. +- [ ] Test two-holder broad annotation → exact exception (TOML remains valid, both holders preserved, sibling unchanged), exact annotation add/replace policy, literal-string/multiline/unknown-key documents, nested root override, malformed existing document byte preservation, and additive sidecar/REUSE behavior. Test a matching license with missing requested copyright as a real change. +- [ ] Run `cargo test --locked --test us2_apply --test us3_comment_style --test us5_sidecars --test safety`; commit `fix: preserve source syntax and effective metadata during apply`. + +## Task 7 — Build a complete preview and derive one truthful outcome + +**Findings:** F03, F15. **Depends on:** tasks 1–6. + +**Files:** modify `src/cli/apply.rs`, `src/report/mod.rs`, `src/report/render.rs`, `src/domain.rs`, `src/error.rs`, `src/cli/check.rs`, `src/cli/lint.rs`, `src/cli/add_license.rs`, `specs/001-declarative-license-headers/contracts/report.schema.json`; extend `tests/us2_partial.rs`, `tests/us1_snapshots.rs`, `tests/us2_apply.rs`. + +**Interfaces:** add concrete write records independent of file states. Use these same records for dry-run and execution. Byte buffers remain internal; JSON serializes text/diffs only for text metadata being written. + +```rust +pub enum WriteKind { Source, Sidecar, ReuseToml, Config, LicenseText } +pub struct PlannedWrite { + pub path: std::path::PathBuf, + pub kind: WriteKind, + pub before: Option>, + pub after: Vec, + pub affected_files: Vec, +} +// Serialized outcome values: planned, applied, unchanged, failed, blocked. +// Reports own an independent writes array; do not join sidecar paths to asset states. +``` + +- [ ] Reproduce `LICENSES` as an ordinary file and copyright-only/no-license source. Assert apply cannot exit 0 with a failed summary or missing required license text. Add an unresolved equal-specificity rule conflict and an ineffective override fixture; all must be nonzero with precise paths/reasons. +- [ ] Split apply into preparation, execution, and final verification. Preparation scans once, derives all source/sidecar/REUSE writes, calculates projected required texts, validates all generated bytes and destinations, and identifies unfixable requirements before mutation. Known invalid config/metadata/path failures prevent all writes. Never fetch during dry-run. Missing bundled text is a planned license write; missing custom text is an explicit blocker; an unbundled permitted fetch is reported as a conditional planned operation, with its URL/destination. A required fetch that has not occurred makes dry-run `projected_pass=false` and exit 1 even with `--allow-curl`; explain that the network result is unverified. Human prompt-only preview also exits 1 without prompting. Test both cases and verify zero fake-curl invocations. +- [ ] Before execution, show/retain the complete plan and apply the dirty-tree guard as a fallible operation. Evaluate expected current bytes before every atomic write. Process independent writes in deterministic destination order; batch metadata-document updates. A runtime failure may allow independent safe writes to finish, but dependent writes must be blocked. Record every attempt and failure. +- [ ] Compute final inventory after text writes/downloads and rescan the frozen selected paths. Do not recompute a changed path set that expands because apply changed files. On post-write read/scan failure, retain attempted write results and produce exit 3 when any write succeeded. +- [ ] Centralize result derivation. The equivalent logic must drive both summary and exit: + +```rust +fn apply_exit(changed: usize, operational_failure: bool, violations: bool) + -> crate::ExitCode +{ + match (changed > 0, operational_failure, violations) { + (true, true, _) => crate::ExitCode::Partial, + (_, true, _) | (_, false, true) => crate::ExitCode::Violations, + (_, false, false) => crate::ExitCode::Success, + } +} +``` + +- [ ] Report schema v2 includes `version`, `command`, `exit_code`, snapshot/scope metadata, `summary` with `pass`, `complete`, `before_pass`, `partial`, counts, actual `files`, structured `diagnostics`, independent `writes`, and license inventory. Dry-run also includes `projected_pass`; its `summary.pass` means projected success and must be labeled as such in human output. `init` joins the same envelope in task 8. Keep JSON field ordering deterministic and diagnostics sorted by path/code. A committed replacement followed by durability failure is a changed write with a diagnostic and partial result. Additive writes that all succeed but leave declaration drift produce exit 1, not partial; test that case explicitly. +- [ ] Human output shows changed and failed destinations even if final file state is compliant. Use distinct words for planned, applied, failed, and blocked. Dry-run shows exact before/after text or a unified diff for each text write, plus license-text destinations and unavailable requirements. Ordinary successful apply shows compact counts and changed paths. Use existing `similar` only if promoted for a justified unified diff; the dependency-free alternative is clearly delimited before/after text, which satisfies this plan. +- [ ] Partial tests must assert status 3, failed path, failed bytes unchanged, successful bytes changed, and independent write statuses. Use deterministic failure (destination turns into a directory between plan/execution via the library-level executor test) rather than permissions alone; do not add production test flags. Keep the Unix permissions integration case as extra coverage. +- [ ] Hash/list the complete fixture tree before/after dry-run, including Git metadata and license texts; assert zero changes. Run real apply on a copy and compare actual destinations and replacement bytes with the preview. Re-run apply and assert an empty changed set and identical bytes. +- [ ] Run `cargo test --locked --test us2_partial --test us2_apply --test us1_snapshots --test us5_sidecars`. Review snapshot changes instead of blindly accepting them; commit `fix: report complete plans and consistent command outcomes`. + +## Task 8 — Make init preserve observed licensing + +**Finding:** F10. **Depends on:** tasks 2–7. + +**Files:** modify `src/cli/init.rs`, `src/cli/mod.rs`, `src/config/mod.rs`, `src/report/mod.rs`; extend `tests/us5_reuse.rs` and the init portion of the CLI contract. + +- [ ] Add a real round-trip regression: two MIT `.rs` files, one Apache-2.0 `.rs`, an exceptional extensionless file, a sidecar-covered binary, nested REUSE coverage, and one unlicensed file. Run init to a fresh output; load its config and resolve each observed path. Every known effective license must be equal before/after; the unknown file must remain uncovered. Do not use substring presence as the projection assertion. +- [ ] Generate exact-path rules for observed effective licensing first. A root filename must be emitted as `./filename` (then normalized as an ExactPath selector) so it cannot accidentally govern the same filename in other directories. Avoid a default unless every covered file is known and a verified exception set preserves all observations. Emit no guessed holder; use preserve by default. +- [ ] Compress only groups with identical licensing for **all** observed paths they would match. Uniform extension groups may become ext rules; heterogeneous groups keep exact exceptions. Directory/glob inference is unnecessary for this repair. Validate generated config and its projection against every observation using the real rule resolver before writing. +- [ ] Add `--force` to init; create-new is the default, and existing files/symlinks are not followed or truncated. Define output as `--output` if provided, otherwise `--config` if explicit, otherwise root/license.toml. Explain `--from-reuse` compatibility behavior in help; it is not a hidden alternate parsing mode. Use shared safe writes and v2 serialization, including paths with quotes/newlines. +- [ ] Include generated rules, unknown paths, and inference counts in the report. `--force` authorizes replacing only the chosen config and still checks expected bytes. An init failure changes no source or existing config. +- [ ] Run `cargo test --locked --test us5_reuse` plus init unit tests; commit `fix: preserve licensing exceptions when generating configuration`. + +## Task 9 — Tighten config validation and CLI ergonomics + +**Findings:** F13–F15. **Depends on:** tasks 2–8. + +**Files:** modify `src/config/mod.rs`, `src/config/schema.rs`, `src/comment/mod.rs`, `src/rules/mod.rs`, `src/cli/mod.rs`, command modules, `src/main.rs`, contracts and README; extend `tests/us3_comment_style.rs`, `tests/us4_enforce.rs`, `tests/completions.rs`. + +- [ ] Validate named comment aliases immediately with `comment::by_alias`; unknown alias is config error 2. A selected line-comment form requires a nonblank prefix; a block form requires both nonblank delimiters. The optional interior `block_line_prefix` may be empty or whitespace, as existing valid block styles require. Reject embedded CR/LF/NUL/control characters in tokens, empty add/replace copyright text, and copyright newlines that could inject additional tags. Invalid config causes no writes. +- [ ] Match comment associations by full normalized ExactPath before Filename before Extension, using the same path semantics as rules. Test `a/foo` and `b/foo` with distinct styles, unknown aliases, empty prefixes, inline block-only syntax, and duplicate selectors with differing copyright intent. Keep `license.toml` glob behavior documented separately from REUSE pattern grammar rather than accidentally changing all globs while fixing OOB matching. +- [ ] Resolve default config from the discovered root for every relevant command. Explicit config/file-list paths remain relative to cwd. All commands distinguish missing optional default, explicit missing config, malformed config, and read error. Keep existing flags/aliases/completions consistent; use clap conflicts where practical instead of delayed bespoke validation. +- [ ] Make `check --explain PATH` resolve the target and matching rules directly. Report winning rule index/selector or default, losing matching rules and specificity, exclusions, metadata sources, and current drift. Respect an explicit selected set; a path outside it/nonexistent returns a usage diagnostic, not success. Honor JSON format for explanations. Do not scan or write caches for the whole tree merely to explain one path. +- [ ] Route all JSON through serde. Move fetch progress and prompts to stderr. In JSON mode, disable implicit interactive prompting unless explicit `--allow-curl` was supplied, so automation never stalls; human TTY mode retains the documented y/N consent. A broken stdout pipe should terminate quietly instead of panicking; return other output errors normally. +- [ ] Add process tests that parse the entire stdout for check/apply/dry-run/lint/init/add-license success and error cases, including quote/newline paths and fake curl. Assert no progress prefix/suffix is present. Generate all supported completions after flag changes. +- [ ] Update examples: check policy vs lint conformance; staged index vs staged-path apply; root/cwd path bases; dirty nonrepo behavior; additive residual drift; safe init overwrite; actual dry-run output. Run `cargo test --locked --test us3_comment_style --test us4_enforce --test completions --test us5_add_license`; commit `fix: validate configuration and make CLI behavior predictable`. + +## Task 10 — Delete inert caching and measure the corrected pipeline + +**Findings:** F12, F16, F17, F19. **Depends on:** tasks 2–9. + +**Files:** modify `src/engine.rs`, `src/walk/mod.rs`, `src/cli/check.rs`, `src/cli/apply.rs`, `src/cli/mod.rs`, `Cargo.toml`, `Cargo.lock`, `benches/scan.rs`, `tests/perf.rs`, `tests/determinism.rs`, README/contracts; delete `src/walk/cache.rs` and unused cache-only plumbing. + +- [ ] Before removing cache, record release-binary wall time for the audit's full 10,000-file and one-file subset workloads on this machine. Record versions, number of selected files, actual byte count, repetitions, and whether filesystem caches are hot; keep errors/status checks in the timing harness. +- [ ] Remove `ScanCache`, content hashing performed only for it, configuration fingerprints, cache flushing, drift-label tuple fields, unused engine config-text storage, and the unused comment resolver inside scan. Retain `--cache`/`--no-cache` for one compatibility window as documented deprecated no-ops, with at most a stderr notice; they must not create files. Update tests to check stateless deterministic behavior rather than fake hit behavior. +- [ ] Remove `gix` now that task 2 owns root/snapshot discovery through system Git. Search every direct dependency use before removing cache-only/unused crates (including sha2 if no other use remains). Use `cargo tree --locked --edges normal` before/after to record the reduction; do not churn unrelated lock versions. +- [ ] Keep precompiled rule/OOB matchers and the existing shared marker automaton. Precompute canonical declared expressions once per configuration. Batch REUSE writes per document and Git object reads per snapshot as already required; do not parallelize writes. Avoid additional rule indexes or worker-pool tuning unless profiling finds a real regression. +- [ ] Repair Criterion: use a real initialized/staged fixture matching policy coverage; do not include a cache artifact; benchmark equivalent file sets and assert count/status equality outside timed loops. Label engine-only timing separately from end-to-end CLI timing. Sizes: 500, 2,000, 10,000 files. Workloads: tiny files, 8 KiB and 1 MiB text with late snippets, binary sidecars, nested metadata, dozens of rules, and one-file staged checks after a full run. +- [ ] Measure wall time and peak RSS for release binaries. Use at least ten repetitions for stable small-file medians; report the initial filesystem-cold-ish sample separately. Keep timing thresholds out of ordinary correctness tests except a generous hang guard. On a named reference runner, retain the original 10,000-small-file targets (<3 s initial scan, <1 s subsequent scan) and report failures honestly. No claim of parity with the old head-only scanner on large files is required; complete metadata scanning does more work. +- [ ] Re-run the long-LicenseRef scaling check after AST normalization; use increasing sizes with timeouts and ensure no quadratic suffix-copying. Profile only if the new whole-file reader or rule matching actually dominates. +- [ ] Run `cargo test --locked --test determinism --test perf` and `cargo bench --locked --bench scan -- --quick`; commit `perf: remove unused scan cache and redundant Git machinery`. + +## Task 11 — Turn conformance and safety evidence into required CI + +**Finding:** F17. **Depends on:** tasks 1–10. + +**Files:** modify `.github/workflows/ci.yml`, `tests/common/mod.rs`, `tests/us5_reuse.rs`, `tests/determinism.rs`, relevant regression suites; add one `tests/reuse_differential.rs` to hold the compact cross-tool fixture matrix. Do not introduce a second fixture framework. + +- [ ] Install the exact known-good comparator in a dedicated Linux CI job: + +```yaml +- name: Install the pinned reference tool in an isolated environment + run: | + python3 -m venv "$RUNNER_TEMP/licet-reuse" + "$RUNNER_TEMP/licet-reuse/bin/python" -m pip install 'reuse[charset-normalizer]==6.2.0' + "$RUNNER_TEMP/licet-reuse/bin/reuse" --version + echo "$RUNNER_TEMP/licet-reuse/bin" >> "$GITHUB_PATH" +- run: cargo test --locked --test reuse_differential --test us5_reuse + env: + LICET_REQUIRE_REUSE: '1' +``` + +Use the Linux runner's Python 3.10 or newer to create the isolated environment; verify its version before installation. This avoids adding another setup action. Include encoding dependencies so `--version` failure cannot silently disable conformance. + +- [ ] Make comparator absence/failure fatal under `LICET_REQUIRE_REUSE=1`; locally, a missing comparator may print an explicit skip. Capture its version and specification version in the test output. Compare semantic fields rather than human output strings. Record mismatches for review rather than blessing Licet output automatically. +- [ ] Required differential fixtures: the five audited disagreements; nested precedence matrix; copyright-only/standard notices; sidecar-over-header; multiple expressions and exceptions; late/unclosed snippets; excluded/zero-byte/symlink/SPDX-document cases; ignored and untracked files; deprecated DEP5; valid custom-license text; missing/empty/unused license files. Compare covered paths, effective references, and pass/fail. When a reference tool limitation differs from normative text, document the exact fixture and normative decision; no silent exclusion. +- [ ] Keep existing fast unit/integration suites and add the table-driven adversarial cases from owning tasks. Full-byte preservation, outside sentinels, idempotent second apply, dry-run equality, exact partial outcomes, non-UTF-8 path identity, and JSON parsing are mandatory. Fuzzing infrastructure is optional; deterministic cases derived from actual bugs come first. A small seeded generator for newline/comment/expression variants is sufficient if extra breadth is needed. +- [ ] Replace the “offline” output-equality test with fake-curl call assertions. Preserve deterministic-report tests separately. In the dependency guard, first run `cargo tree --locked --edges normal --prefix none > dependency-tree.txt` successfully; only then check the denylist. A failed cargo command is a failed check. The denylist is dependency hygiene, not proof that subprocesses cannot access the network. +- [ ] Use `--locked` in all CI compilation/check/test commands. Run tests on Linux/macOS/Windows and MSRV checks with **all targets**. Keep Criterion performance runs in their own job; use `cargo test --lib --bins --tests` and `cargo test --doc` for ordinary correctness rather than accidentally running Criterion's harness during every all-target test job. +- [ ] Execute locally with the comparator required, then run full final checks from the last section. Commit `test: require REUSE conformance and adversarial safety checks`. + +## Task 12 — Repair release gating and align the public contract + +**Findings:** F18, F19. **Depends on:** tasks 10 and 11. + +**Files:** modify `.github/workflows/release.yml`, `.github/workflows/prepare-release.yml`, `build.rs`, `mise.toml`, README, `CLAUDE.md`, `CHANGELOG.md` unreleased notes, affected spec/contracts. Do not bump/publish a package version without current remote-state review. + +- [ ] Add job-local OIDC permissions to `publish-crate`: + +```yaml +permissions: + contents: read + id-token: write +``` + +- [ ] Gate publishing on the exact tagged commit passing locked tests, conformance, and version validation. Prefer making the existing CI workflow reusable through `workflow_call`; have tag release depend on that job. Produce a draft GitHub release, build/upload every expected artifact and checksum, publish the crate only after verification, and mark the release public last. Finalization must explicitly accept either successful stable crate publication or intentionally skipped prerelease crate publication; ordinary `needs: publish-crate` skip propagation is insufficient. Require successful validation and all artifact jobs in either case. Validate this condition matrix: stable+all success → public; prerelease+all artifacts success+crate intentionally skipped → public prerelease; any validation/artifact/publish failure → draft; cancelled upstream job → draft. An error leaves a clearly incomplete draft, not a public success-shaped release. +- [ ] Add an operable manual tagged-ref release path or explicitly dispatch the release workflow from prepare-release when using GITHUB_TOKEN. Validate a supplied tag to an existing immutable commit and check manifest version before mutations. If retaining PAT-trigger-only behavior, fail before commit/tag/push when the credential is absent; do not retain documentation for a nonexistent manual path. Preserve recovery by rerunning only failed jobs; no automatic deletion/recreation of tags or published versions. +- [ ] Validate build assets: missing/empty directory or unreadable entry fails the build; all selected text files must be nonempty valid text with known IDs. Register `cargo:rerun-if-env-changed=LICET_SPDX_LIST_VERSION` while it exists. Separate reported SPDX recognition-list version from text-bundle provenance/version and bundle count. There are currently 14 texts; document that subset honestly instead of claiming the whole corpus is embedded. +- [ ] Align mise with the stable/MSRV policy and remove the unrelated installed Licet pin from the development path or replace it with a `cargo run --` task. Do not require nightly-only behavior. Keep a reproducible pinned comparator for conformance and document its encoding extra. +- [ ] Update CLI/config/report contracts and examples to the final implementation, including intentional compatibility changes listed earlier. Remove obsolete cache promises, gix subset claims, blanket OOB override wording, and unsupported “always preserves” claims where explicit copyright replace is available. Keep a short migration note for v2 JSON, staged snapshots, additive residual drift, symlink exclusions, and init overwrite protection. +- [ ] Validate workflow syntax and job dependency/permission structure locally; run `cargo package --locked` and inspect packaged assets in an actual repository, with release publication disabled. Document any missing live registry/environment verification. Commit `ci: gate releases on verified artifacts and conformance`. + +## Final acceptance and handoff + +- [ ] Every F01–F19 finding maps to a completed task and at least one concrete check or documented source/workflow validation. Do not close a reproduced behavioral finding using only a comment change. +- [ ] Run the final suite with test Git signing/hooks isolated and the reference tool required: + +```sh +cargo fmt --all --check +cargo clippy --locked --all-targets -- -D warnings +LICET_REQUIRE_REUSE=1 cargo test --locked --lib --bins --tests +cargo test --locked --doc +cargo +1.89 check --locked --all-targets +cargo bench --locked --bench scan -- --quick +cargo package --locked +``` + +- [ ] Re-run the five audited conformance counterexamples and the source-corruption/symlink/permission/curl cases against the final release binary, independently of unit-test helpers. +- [ ] Confirm dry-run performs no writes and predicts actual destinations/text; second apply is byte-identical and records no applied writes; JSON pass/exit/partial fields agree; no missing copyright, invalid metadata, or unreadable selected dependency can produce a false REUSE pass. +- [ ] Have a fresh reviewer trace selection → snapshot → effective metadata → inventory → edit plan → write → final report for staged files, nested overrides, binary sidecars, and partial failures. Review tests against supplied REUSE text and reference results, not against agent consensus. +- [ ] Report final source commit/diff, executed checks and actual counts, platform coverage, reference-tool version, performance measurements with workload limits, and remaining issues. Do not state that release delivery is tested live unless it was separately authorized and verified. Leave a reviewable implementation and updated documentation; no tag or registry publication is part of this plan. diff --git a/specs/001-declarative-license-headers/contracts/cli.md b/specs/001-declarative-license-headers/contracts/cli.md index 43ea9e8..d7f1ed9 100644 --- a/specs/001-declarative-license-headers/contracts/cli.md +++ b/specs/001-declarative-license-headers/contracts/cli.md @@ -18,15 +18,26 @@ arguments → stdout for results, errors/diagnostics → stderr. Every command a RuleConflict, missing license text} to exit `1`. `Uncovered` (FR-012a) and `Unreadable` (FR-025) **count as failure**. `Excluded` does not. -`apply` exit semantics: `0` when the run leaves every selected file Compliant or Excluded; -`1` when files remain non-compliant that `apply` cannot fix by writing — specifically -**Uncovered** (needs a config edit, not a header) and **Unreadable** (non-UTF-8, never -modified); `3` when some writes succeeded and others failed (FR-021). +`apply` exit semantics derive from one observation triple `(changed, operational_failure, +violations)` (FR-021): `3` when writes were applied and the tool itself also failed +(unreadable inputs, refused plan targets, failed writes, failed verification); `1` when +nothing was applied but the run failed operationally, or when drift or blocked +requirements remain — including writes that all succeeded but leave declaration drift +(additive contradictions, unfixable entries, missing required license texts); `0` only +when the gate passes with no operational failure. `Uncovered` (needs a config edit, not +a header) and `Unreadable` (non-UTF-8, never modified) are violations, not operational +failures. Dry-run never writes or fetches: `summary.pass` and `projected_pass` predict +the gate had the plan executed, and the exit mirrors the prediction (`0` iff projected +pass). Every planned, applied, failed, and blocked write is listed in `writes`, each +with its destination, kind, outcome, covered files, and — for text being written — the +exact bytes as text. `add-license` exit semantics: `0` when every targeted text is present in `LICENSES/` -afterward; `1` when one or more requested/referenced texts could not be supplied (absent -from the bundle and not a `LicenseRef-*`, with `--allow-network` not given), naming the -identifier; `2` on flag misuse (neither identifiers nor `--all` given, or both). +afterward; `1` when one or more requested/referenced texts could not be supplied (a +`LicenseRef-*` needing a maintainer-supplied text, or an unbundled standard id that was +not fetched), naming the identifier; `2` on flag misuse (neither identifiers nor `--all` +given, or both) and on invalid identifiers (unknown id, path escape, compound +expression) — raised before any directory is created or any download is attempted. ## Global flags @@ -34,11 +45,11 @@ identifier; `2` on flag misuse (neither identifiers nor `--all` given, or both). |------|-------------| | `--config ` | Path to declarative config (default `./license.toml`). | | `--format human\|json` | Output rendering. `json` conforms to `report.schema.json`. | -| `--files …` / `--files-from ` / `-` (stdin) | Restrict evaluation to a supplied subset (FR-013). | -| `--staged` | Restrict to git-staged files (commit-hook mode, FR-013). | -| `--changed []` | Restrict to files changed vs `` (default `HEAD`). | -| `--no-cache` / `--cache ` | Disable or relocate the scan cache (SC-006). The cache key folds in file content + effective config + tool version, so it is never stale (FR-023, SC-011). | -| `--explain ` | Print which rule matched `` and why (FR-002, FR-022). | +| `--files …` / `--files-from ` / `-` (stdin) | Restrict evaluation to a supplied subset (FR-013). Paths are relative to the invocation cwd (or absolute), normalized lexically; outside-root and nonregular inputs are usage errors, symlinks are ignored like everywhere else. | +| `--staged` | Restrict to git-staged files (commit-hook mode, FR-013). `check --staged` evaluates **index blobs** — including staged metadata, config, and license texts — so the gate sees the commit as it would land; `apply --staged` uses the staged **path set** but edits working-tree files and never stages its edits. | +| `--changed []` | Restrict to files changed vs `` (default `HEAD`; `` must resolve to a commit). Evaluates current working-tree bytes for those paths. | +| `--no-cache` / `--cache ` | Deprecated no-ops retained for one compatibility window (SC-006): scans are stateless and never create files; at most a stderr notice is printed. | +| `--explain ` | Resolve one path directly (no whole-tree scan, no cache writes): winning rule number/selector or default, losing matches with specificity, exclusions, metadata provenance, current drift (FR-002, FR-022). Respects an explicit selected set; a path outside it or not on disk is a usage error (exit `2`). Honors `--format json` with a single-file report. | | `--version` | Print the tool version **and the embedded SPDX license-list version** (FR-028). | **Selection flags are mutually exclusive** (FR-027): supplying more than one of @@ -50,9 +61,20 @@ identifier; `2` on flag misuse (neither identifiers nor `--all` given, or both). licet check [--staged | --changed [] | --files …] [--format …] ``` - Never modifies files. +- Default coverage in a Git repository is **tracked regular files** (a later + `.gitignore` rule cannot drop a tracked file; untracked files are not covered). + Outside a repository, the nonignored filesystem walk is used. - Projects config onto the selected files; classifies each as - Compliant / WrongLicense / MissingHeader / Uncovered / Excluded (FR-004). + Compliant / WrongLicense / CopyrightMismatch / MissingHeader / Uncovered / Excluded (FR-004). + License equality joins all effective expressions as one `AND` expression; copyright + is compared separately against the policy. +- Requires the license texts referenced by the selected files (actual effective plus + declared desired scope): missing texts fail the gate with a `missing_license_text` + diagnostic. Texts of unmatched rules never enter the scope. - Output names each offending file with **declared vs actual** identifiers (SC-009). +- When a staged/changed subset contains licensing metadata (config, `REUSE.toml`, + `.reuse/dep5`, sidecars, `LICENSES/` texts) that can affect other files, the check + conservatively expands to full tracked coverage and reports the expansion. - Exit `0` only if every selected file is Compliant or Excluded. **Acceptance (from spec)**: drifted staged file → exit `1` with the offending file named; @@ -68,8 +90,11 @@ licet apply [--additive] [--target-header ] [--allow-dirty] `SPDX-License-Identifier` to match config; **always preserves** copyright/authorship (FR-007, FR-009, SC-004). - **Safety (FR-024, SC-010)**: refuses to modify files when the working tree has - uncommitted changes unless `--allow-dirty` is passed (exit `2` on refusal). Every write - is **atomic** (temp file + rename) so an interruption never leaves a file half-written. + uncommitted changes unless `--allow-dirty` is passed (exit `2` on refusal). A Git + launch/status failure never means "clean" (exit `2`); outside a repository there is + no Git undo guarantee, so `--allow-dirty` is required there too. Every write goes + through one contained atomic writer (expected-bytes guard, no symlink traversal, + permission preservation, fsync) so an interruption never leaves a file half-written. - **Encoding (FR-025)**: non-UTF-8 files are never byte-edited. An uncovered one is reported as `Unreadable` and contributes to a non-zero exit; one already covered by a sidecar or REUSE.toml annotation is read through that coverage. Existing newline conventions (LF/CRLF) @@ -79,9 +104,11 @@ licet apply [--additive] [--target-header ] [--allow-dirty] `.license` sidecar (bare SPDX lines); `--non-annotatable reuse-toml` (or `[output] non_annotatable = "reuse-toml"`) appends an idempotent `REUSE.toml` annotation instead. A file already *correctly* covered out-of-band is left untouched; one covered but - with the wrong license is reconciled where the coverage lives — a `REUSE.toml` annotation - is rewritten in place, or, when only a glob matches, a more-specific exact-path annotation - is appended so it wins by last match (REUSE 3.3). Legacy `.reuse/dep5` coverage is flagged + with the wrong license is reconciled where the coverage lives — a superseding exact-path + `REUSE.toml` annotation is appended so it wins by last match (REUSE 3.3), carrying + `precedence = "override"` when the file sits behind an `override` barrier. The existing + document is never rewritten in place, so comments and unrelated stanzas survive + byte-for-byte and a rerun converges to a no-op. Legacy `.reuse/dep5` coverage is flagged for manual fixup rather than rewritten. The flag overrides config. - `--additive`: adds the declared header without removing existing license lines; warns on resulting contradiction (FR-020). @@ -93,7 +120,8 @@ licet apply [--additive] [--target-header ] [--allow-dirty] **`apply --dry-run`** answers "exactly what would `apply` change?" (per-file before/after). - Writes missing headers in the file's resolved comment style (FR-011), respecting shebang/encoding first-lines (FR-019). Materializes missing standard license texts into - `LICENSES/` from the offline bundle; scaffolds `LicenseRef-*` placeholders (FR-017). + `LICENSES/` from the offline bundle; `LicenseRef-*` texts are never invented — the + maintainer supplies them (FR-017). - On partial failure: exit `3`, report changed vs unchanged files (FR-021). **Acceptance**: destructive replaces a wrong `LicenseRef-MarqueLicense-1.0` under @@ -103,21 +131,52 @@ survive a license-only replace; a specific header can be targeted. ## `init` / `bootstrap` — derive config from current state (FR-018; US5) ``` -licet init [--from-reuse] [--output ] +licet init [--from-reuse] [--output ] [--config ] [--force] [--format …] ``` - Inspects existing headers and any `REUSE.toml`/`.reuse/dep5`, then generates an initial - `license.toml` whose projection reproduces the repository's current licensing (SC-008). -- Does not modify source files; writes only the config (and reports what it inferred). + config whose projection reproduces the repository's current licensing (SC-008). + Preservation outranks brevity: every observed file first becomes an exact-path + rule (a root-level file is emitted as `file = "./name"` so it cannot govern + deeper namesakes); rules compress to an extension group only when every + observed path they would match carries the same license; a `[default]` is + emitted only when every covered file is known, with exact exceptions for the + rest. Previously unknown files remain unknown. Copyright is always `preserve`; + no holder is ever guessed. The generated config is validated against every + observation with the real rule resolver before anything is written, and a + validation failure writes nothing. +- Does not modify source files; writes only the config. Destination: `--output`, + else explicit `--config`, else `/license.toml` (explicit relative paths + resolve from the invocation cwd; an explicit destination outside the project + authorizes only that config file). Create-new is the default: an existing + destination (file or symlink) is refused with exit `2` unless `--force` + replaces exactly the bytes just observed. `--from-reuse` is a documented + compatibility alias — inspection already covers REUSE state. +- `--format json` joins the report envelope (`command: "init"`, `summary.pass` + = config written and projection verified, one `config` write record carrying + the generated TOML, unknown paths as `missing_license` diagnostics). ## `lint` — REUSE-compatibility & license-text report (FR-014, FR-017; US5) ``` -licet lint [--allow-network] +licet lint [--allow-network] [--config ] ``` -- Reports REUSE conformance posture: SPDX headers present, `LICENSES/` completeness, - out-of-band coverage for non-annotatable files. -- Lists referenced-but-missing license texts. Resolves known ids from the **offline - bundle**; `--allow-network` permits fetching only ids absent from the bundle (FR-017). +- Reports REUSE conformance posture over **actual** metadata, independently of any + declared policy: every covered file needs a license expression and a copyright + notice (`missing_license` / `missing_copyright` diagnostics), malformed values + are `invalid_license`, and unreadable inputs make validation incomplete + (`read_error` / `unsupported_encoding`) rather than a proven violation or a pass. +- Covers tracked **plus** nonignored untracked files in a repository (the policy + gate's tracked-only default does not apply), and never applies declaration + `[exclude]` rules — a config exclusion cannot hide a file from whole-project + REUSE validation. Needs no configuration: an auto-discovered `license.toml` is + just another covered file. An explicit `--config ` must exist and parse + (exit `2` otherwise); it is accepted with a deprecation notice and never changes + evaluation. +- Lists referenced-but-missing license texts, plus project-wide findings: unused + texts, unrecognized `LICENSES/` entries, missing filename extensions, and + undecodable texts. Duplicate ids under several filenames are usage error (exit + `2`). Resolves known ids from the **offline bundle**; `--allow-network` permits + fetching only ids absent from the bundle (FR-017). - Reports the embedded **SPDX license-list version** so the compliance posture is auditable (FR-028). - Exit `1` if the repository would not pass a REUSE-spec compliance check. @@ -132,14 +191,24 @@ licet add … offline analog of REUSE's `download`. Because the SPDX corpus is embedded, this is a copy from the bundle, never a network fetch for a bundled identifier (offline-guard invariant). - ` …`: materialize exactly these identifiers. `--all`: materialize every - identifier referenced by the config and existing headers that is **missing** from - `LICENSES/` (the parallel of `reuse download --all`). Supplying neither — and not - `--all` — is a usage error → exit `2`; supplying both is also exit `2`. -- `LicenseRef-*` identifiers are scaffolded as empty placeholder texts for the maintainer to - fill in (FR-017); they are never fetched. -- `--allow-network`: permits fetching identifiers absent from the bundle; without it, an - unbundled non-`LicenseRef` identifier cannot be supplied and the run exits `1`, naming it - (consistent with `lint`). + identifier in the union of desired (declared) and actual (detected) references + that is **missing** from `LICENSES/` (the parallel of `reuse download --all`). + A malformed policy config is usage error → exit `2`, never swallowed. + Supplying neither — and not `--all` — is a usage error → exit `2`; supplying + both is also exit `2`. +- Every requested identifier is validated before any directory is created or any + download is attempted: unknown ids, path escapes (`../x`, absolute paths, separators), + control characters, and compound expressions passed as one id are usage errors → exit + `2`. Standard-id spelling is canonicalized (`mit` → `MIT`). +- `LicenseRef-*` identifiers are reported as required local texts for the maintainer to + supply at `LICENSES/.txt` (FR-017); they are never fetched and no placeholder prose + is ever invented. An unsupplied custom text exits `1`, naming it. +- `--allow-network`: permits fetching valid-but-unbundled standard ids with the system + `curl` binary into owned temporary storage (HTTPS-only, bounded execution, 4 MiB cap, + nonempty UTF-8 validated) before installing through the safe writer; a failed download + never deletes or truncates the destination. Without it, an unbundled non-`LicenseRef` + identifier cannot be supplied and the run exits `1`, naming it (consistent with `lint`). + Bundled texts never invoke the network. - Writes **only** under `LICENSES/`: it never modifies source files or `license.toml`, and therefore — unlike `apply` — does **not** require a clean working tree. (`apply` still materializes texts as a side-effect of annotating; `add-license` exposes that @@ -154,9 +223,14 @@ licet add … - **Subset honoring**: when a selection flag is given, only those files are evaluated (FR-013), but rule precedence still considers the full ruleset. Selection flags are mutually exclusive (FR-027). -- **Symlink safety**: a file reached via symlink is annotated once (Edge Cases). -- **Atomic & non-destructive to copyright**: writes are temp-file-plus-rename (FR-024); - copyright/authorship is preserved by default (FR-009, SC-004). +- **Symlink safety**: symlinks are never written through — a symlink destination or + symlink ancestor aborts the write, and symlinked `LICENSES/` entries do not count as + present texts. Callers must use the real path. +- **Contained atomic writes & non-destructive to copyright**: every mutation goes through + one helper that confines the destination to its allowed root, requires the caller to + state the expected current bytes (`None` = must not exist), preserves file permissions, + fsyncs content plus the directory, and re-verifies the destination before renaming + (FR-024); copyright/authorship is preserved by default (FR-009, SC-004). - **Ignore blocks & snippets**: SPDX tags between `REUSE-IgnoreStart`/`REUSE-IgnoreEnd` are ignored during detection (unclosed → to end of input); SPDX-snippet licenses (`SPDX-SnippetBegin`..`SPDX-SnippetEnd`) never count as the file's license but are still @@ -170,3 +244,34 @@ licet add … comment-style, or version changes invalidate affected entries (FR-023, SC-011). - **Version transparency**: `--version` and `lint` report the embedded SPDX list version (FR-028). +- **Path bases**: explicit file arguments are relative to the invocation cwd; + declaration selectors and metadata paths are relative to their documented + base. An omitted `--config` resolves from the discovered root + (`/license.toml`); an explicit relative `--config` stays cwd-relative. +- **Output streams**: `--format json` prints exactly one serialized document to + stdout; progress, prompts, and human diagnostics go to stderr. The tool never + prompts implicitly — automation never stalls; network happens only with an + explicit `--allow-network`. A closed stdout pipe terminates quietly (exit 0) + instead of panicking; other output errors are reported normally. + +## Examples + +- Policy vs conformance: `licet check` gates declared policy over selected + files (needs their license texts); `licet lint` validates actual REUSE 3.3 + metadata over the whole project, no config needed. A `preserve` project can + pass `check` yet fail `lint` for missing copyright — run both. +- Snapshots: `licet check --staged` evaluates index bytes (what would land); + `licet apply --staged` edits the working tree for the staged path set and + never stages its edits (dirty-tree guard still applies). +- Scopes: from `pkg/`, `licet check` uses the root config by default, while + `licet check --config ./local.toml` reads `./local.toml` under `pkg/`. +- Dirty non-repo: outside Git there is no undo guarantee, so `apply` requires + `--allow-dirty` there just like for a dirty tree. +- Additive drift: `licet apply --additive` keeps old identifiers; when the + combination still differs from intent it exits `1` with a residual-drift + diagnostic — success of the writes, failure of the gate. +- Safe init: `licet init` refuses to overwrite `license.toml`; re-run with + `licet init --force`. +- Preview: `licet apply --dry-run` lists every planned/applied write with + before/after text and exits nonzero when the projected gate fails — running + the real `apply` afterwards produces those exact bytes. diff --git a/specs/001-declarative-license-headers/contracts/config-schema.md b/specs/001-declarative-license-headers/contracts/config-schema.md index 1753470..1f2503e 100644 --- a/specs/001-declarative-license-headers/contracts/config-schema.md +++ b/specs/001-declarative-license-headers/contracts/config-schema.md @@ -18,7 +18,9 @@ not designed for declarative intent. license = "MIT OR Apache-2.0" # SPDX expression or LicenseRef-* copyright = "preserve" # "preserve" (default) | "add:" | "replace:" -# Ordered rules. Declaration order breaks specificity ties (FR-002). +# Ordered rules. Equal-specificity rules with identical full intent resolve to the +# earliest declaration; equal-specificity rules with differing intent are a +# conflict, never a silent override (FR-002, FR-022). # Each rule has exactly one selector key: ext | glob | file. [[rule]] ext = "rs" # extension selector @@ -36,6 +38,10 @@ license = "LicenseRef-MarqueLicense-1.0" file = "hk.pkl" # exact-filename selector (most specific) license = "LicenseRef-MarqueLicense-1.0" +[[rule]] +file = "./Makefile" # leading ./ pins a root-level file as an +license = "MIT" # exact path (bare names match any dir) + # Comment-style associations (FR-010, FR-011). Persisted — no per-file flags. [[comment_style]] ext = "pkl" @@ -61,13 +67,13 @@ non_annotatable = "sidecar" # "sidecar" (default) | "reuse-toml" | Key | Type | Required | Notes | |-----|------|----------|-------| | `license` | SPDX expression string | recommended | Omit to allow `Uncovered` classification (which fails the gate, FR-012a). | -| `copyright` | string | no | `preserve` (default) \| `add:` \| `replace:` (FR-009). | +| `copyright` | string | no | `preserve` (default) \| `add:` \| `replace:` (FR-009). The text must be nonempty and a single line (no tag injection). | ### `[[rule]]` Exactly one selector key, plus intent: | Key | Type | Notes | |-----|------|-------| -| `ext` \| `glob` \| `file` | string | The selector. Specificity: `file` > `glob` > `ext` (FR-002). | +| `ext` \| `glob` \| `file` | string | The selector. Specificity: `file` > `glob` > `ext` (FR-002). Declaration `glob` uses globset syntax (where `*` may cross `/`) — deliberately distinct from the REUSE.toml pattern grammar, where `*` never crosses `/`. | | `license` | SPDX expression | Required. Validated against SPDX list / `LicenseRef-*`. | | `copyright` | string | Optional per-rule override of the default copyright policy. | @@ -77,13 +83,15 @@ silently resolved. ### `[[comment_style]]` | Key | Type | Notes | |-----|------|-------| -| `ext` \| `file` | string | Selector; `file` takes precedence over `ext` (FR-011). | +| `ext` \| `file` | string | Selector; exact-path associations match the full normalized path, then filename, then extension (FR-011). A leading `./` pins a root file as an exact path. | | `style` | string \| inline table | Built-in style **alias** (e.g. `c`, `hash`, `slashes`), or an inline table that lowers into a `CommentSyntax` (data-model §4). | Inline-table keys: `line_prefix`, `block_start`, `block_end`, `block_line_prefix` (the internal block alignment prefix). The result is classified by which keys are present: `line_prefix` only → line-only; `block_start` + `block_end` (+ optional -`block_line_prefix`) → block-only; both → supports both forms. +`block_line_prefix`) → block-only; both → supports both forms. A line form needs a +nonblank prefix, a block form needs both nonblank delimiters; tokens must not +contain CR/LF/NUL/control characters (`block_line_prefix` alone may be blank). ### `[exclude]` | Key | Type | Notes | @@ -99,11 +107,14 @@ internal block alignment prefix). The result is classified by which keys are pre 1. Every `license` parses as a valid SPDX expression or `LicenseRef-*` (FR-005). 2. Each `[[rule]]` / `[[comment_style]]` has **exactly one** selector key. -3. Every `style` reference resolves to a built-in alias or inline-defined style. An inline +3. Every `style` reference resolves to a built-in alias or inline-defined style — + an unknown alias is a config error, never a silent fallback. An inline style must define at least one form (`line_prefix` or a block), and a block must give **both** `block_start` and `block_end` — a half-specified block is a config error. + Blank prefixes/delimiters and CR/LF/NUL/control characters in tokens are + config errors (`block_line_prefix` alone may be blank). 4. Glob patterns are well-formed. -5. Duplicate identical selectors with differing intent are reported as conflicts (FR-022). +5. Duplicate identical selectors with differing full intent — license expression or copyright policy, compared normalized — are reported as conflicts (FR-022). ## Worked example → projection diff --git a/specs/001-declarative-license-headers/contracts/report.schema.json b/specs/001-declarative-license-headers/contracts/report.schema.json index 7915039..2c444d1 100644 --- a/specs/001-declarative-license-headers/contracts/report.schema.json +++ b/specs/001-declarative-license-headers/contracts/report.schema.json @@ -7,22 +7,27 @@ "required": ["version", "command", "summary", "files"], "additionalProperties": false, "properties": { - "version": { "type": "integer", "const": 1 }, + "version": { "type": "integer", "const": 2 }, "command": { "type": "string", "enum": ["check", "apply", "lint", "add-license"] }, "exit_code": { "type": "integer", "enum": [0, 1, 2, 3] }, + "snapshot": { "type": "string", "description": "How the evaluated content was sourced: worktree or index." }, "summary": { "type": "object", - "required": ["pass", "counts"], + "required": ["pass", "complete", "counts"], "additionalProperties": false, "properties": { - "pass": { "type": "boolean", "description": "True only if no file is WrongLicense/MissingHeader/Uncovered/Unreadable and no unresolved conflict (FR-012a, FR-025, SC-009)." }, - "partial": { "type": "boolean", "description": "Apply could not complete fully (FR-021)." }, + "pass": { "type": "boolean", "description": "True only if no file is WrongLicense/CopyrightMismatch/MissingHeader/Uncovered/Unreadable, no unresolved conflict, and no blocked license text (FR-012a, FR-025, SC-009). For apply dry-run, true means projected success. Lint additionally requires a complete license-text inventory." }, + "partial": { "type": "boolean", "description": "Apply wrote files and the tool itself also failed (FR-021)." }, + "complete": { "type": "boolean", "description": "False when final verification did not run and the report rests on planned data." }, + "before_pass": { "type": ["boolean", "null"], "description": "Apply only: gate state before apply ran." }, + "projected_pass": { "type": ["boolean", "null"], "description": "Apply dry-run only: whether executing the planned writes is projected to pass." }, "counts": { "type": "object", "additionalProperties": false, "properties": { "compliant": { "type": "integer", "minimum": 0 }, "wrong_license": { "type": "integer", "minimum": 0 }, + "copyright_mismatch": { "type": "integer", "minimum": 0, "description": "Files whose license matches but whose copyright policy is unsatisfied." }, "missing_header": { "type": "integer", "minimum": 0 }, "uncovered": { "type": "integer", "minimum": 0 }, "excluded": { "type": "integer", "minimum": 0 }, @@ -43,7 +48,7 @@ "path": { "type": "string" }, "drift": { "type": "string", - "enum": ["compliant", "wrong_license", "missing_header", "uncovered", "excluded", "unreadable"] + "enum": ["compliant", "wrong_license", "copyright_mismatch", "missing_header", "uncovered", "excluded", "unreadable"] }, "declared": { "type": ["string", "null"], "description": "Declared SPDX expression from the matched rule or default." }, "actual": { "type": ["string", "null"], "description": "Detected SPDX expression (in-file header or out-of-band)." }, @@ -73,26 +78,66 @@ "applied": { "type": "boolean", "description": "False on dry-run or when this file's write failed (FR-021)." } }, "description": "Present for apply (or dry-run preview). Copyright preserved by default (SC-004)." + }, + "metadata_origins": { + "type": "array", + "description": "Contributing out-of-band tables, shallowest document first (FR-003a).", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "metadata": { "type": "string" }, + "table": { "type": "integer", "minimum": 0 }, + "precedence": { "type": "string", "enum": ["closest", "aggregate", "override"] }, + "licenses": { "type": "array", "items": { "type": "string" } }, + "copyrights": { "type": "array", "items": { "type": "string" } } + }, + "required": ["metadata", "table", "precedence", "licenses", "copyrights"] + } } } } }, - "warnings": { + "coverage": { + "type": "array", + "description": "Lint only: sorted repo-relative covered paths evaluated for REUSE validation (REUSE-ignored files excluded). Compared against the reference tool's file list in the differential suite.", + "items": { "type": "string" } + }, + "diagnostics": { "type": "array", + "description": "Structured diagnostics, sorted by path, then code.", "items": { "type": "object", - "required": ["kind", "message"], + "required": ["code", "message"], "additionalProperties": false, "properties": { - "kind": { + "code": { "type": "string", - "enum": ["contradiction", "rule_conflict", "missing_license_text", "partial_apply", "source_override", "encoding_skipped"] + "enum": ["contradiction", "rule_conflict", "missing_license_text", "unused_license_text", "bad_license_text", "missing_license_extension", "partial_apply", "source_override", "encoding_skipped", "missing_copyright", "missing_license", "invalid_license", "read_error", "unsupported_encoding", "copyright_mismatch", "orphan_sidecar", "selection_expanded", "unfixable"] }, "path": { "type": ["string", "null"] }, "message": { "type": "string" } } } }, + "writes": { + "type": "array", + "description": "Independent write records: one per planned/applied/failed/blocked write. Never joined to file states; a metadata patch covers many files (FR-021).", + "items": { + "type": "object", + "required": ["path", "kind", "status", "affected_files"], + "additionalProperties": false, + "properties": { + "path": { "type": "string", "description": "Destination written (the document itself for metadata patches)." }, + "kind": { "type": "string", "enum": ["source", "sidecar", "reuse_toml", "license_text", "config"] }, + "status": { "type": "string", "enum": ["planned", "applied", "unchanged", "failed", "blocked"] }, + "affected_files": { "type": "array", "items": { "type": "string" } }, + "before_text": { "type": ["string", "null"], "description": "Exact current bytes as text, for text being written." }, + "after_text": { "type": ["string", "null"], "description": "Exact installed bytes as text; absent when unknown until execution." }, + "message": { "type": ["string", "null"], "description": "Reason for failed/blocked writes (fetch URL, missing id)." } + } + } + }, "license_texts": { "type": "object", "description": "License-text inventory (FR-014, FR-017).", @@ -102,7 +147,10 @@ "present": { "type": "array", "items": { "type": "string" } }, "missing": { "type": "array", "items": { "type": "string" } }, "bundled_available": { "type": "array", "items": { "type": "string" } }, - "spdx_list_version": { "type": "string", "description": "Version of the embedded SPDX license list this binary carries (FR-028)." } + "spdx_list_version": { "type": "string", "description": "Version of the embedded SPDX license list this binary carries (FR-028)." }, + "unused": { "type": "array", "items": { "type": "string" }, "description": "Present but never referenced (project-wide lint finding only)." }, + "unrecognized": { "type": "array", "items": { "type": "string" }, "description": "LICENSES/ entries naming no recognizable license." }, + "missing_extension": { "type": "array", "items": { "type": "string" }, "description": "Recognized ids kept in extensionless files." } } } } diff --git a/specs/001-declarative-license-headers/data-model.md b/specs/001-declarative-license-headers/data-model.md index 5e52733..6ad947a 100644 --- a/specs/001-declarative-license-headers/data-model.md +++ b/specs/001-declarative-license-headers/data-model.md @@ -54,7 +54,9 @@ A matcher paired with the intent it confers (FR-001, FR-002). **Selector** (one of): - `Extension(string)` — e.g. `rs`, `pkl` - `Glob(pattern)` — e.g. `examples/**/*.rs`, `vendor/**` -- `ExactPath(path)` / `Filename(name)` — e.g. `hk.pkl`, `README.md` +- `ExactPath(path)` / `Filename(name)` — e.g. `hk.pkl`, `README.md`. A `file` + value with a leading `./` normalizes to `ExactPath` without the prefix, so + `./Makefile` pins the root file while bare `Makefile` matches any directory. **State/derivation rules**: - Resolution picks the highest `(specificity, then earliest source_order)` match. @@ -139,26 +141,39 @@ What is really present for a file, gathered by detection (FR-003a). | Field | Type | Notes | |-------|------|-------| -| `headers` | list of `HeaderBlock` | File-level SPDX header occurrences. Normally parsed from the file head; when a `.license` **sidecar** exists, its headers are used instead (the REUSE spec treats sidecar content as "inside the file"), so a binary asset can be covered without byte access. Tags inside `REUSE-IgnoreStart`/`REUSE-IgnoreEnd` and inside SPDX snippets are excluded from this list (FR-030). | +| `headers` | list of `HeaderBlock` | File-level SPDX header occurrences. Parsed from the complete file content (no head cutoff); when a `.license` **sidecar** exists, its headers are used instead (the REUSE spec treats sidecar content as "inside the file"), so a binary asset can be covered without byte access. Tags inside `REUSE-IgnoreStart`/`REUSE-IgnoreEnd` and inside SPDX snippets are excluded from this list (FR-030). Rejected `SPDX-License-Identifier` values are kept with line + reason for diagnosis, never dropped silently. | | `snippet_licenses` | list of SPDX expression | Licenses declared inside `SPDX-SnippetBegin`..`SPDX-SnippetEnd` regions. These describe snippets, not the file, so they never affect drift — but they are added to the referenced license-text set for `LICENSES/` completeness (FR-030). | -| `out_of_band` | optional `OutOfBandEntry` | License/copyright + `precedence` from `REUSE.toml` or `.reuse/dep5` covering this path. Read for interop/detection only — never an authoring surface. | -| `detected_license` | optional SPDX expression | The primary resolved license. | +| `out_of_band` | optional `OutOfBandEntry` | Hierarchy-resolved `REUSE.toml`/`.reuse/dep5` coverage for this path (unconditional values + per-field `closest` fallbacks + contributing-table provenance). Read for interop/detection only — never an authoring surface. | +| `detected_license` | optional SPDX expression | The primary resolved license (first file-level, else first unconditional OOB, else first fallback). | | `detected_source` | optional `ActualSource` | One of `Header`, `Sidecar` (`license_file`), `ReuseToml`, `Dep5`. | -| `detected_copyrights` | list of string | All `SPDX-FileCopyrightText` lines found (always aggregated across sources; copyright is never erased). | +| `detected_copyrights` | list of string | Effective copyrights: file-level plus unconditional OOB notices, plus the `closest` fallback only when the file carries none. Raw suppressed notices stay in `headers` for preservation. | | `encoding_ok` | bool | False only when the asset is not valid UTF-8 **and** has no sidecar/out-of-band coverage; drives `Unreadable` (FR-025). A non-UTF8 binary covered by a sidecar or annotation is readable. | -**Precedence (FR-003a)** — how an `out_of_band` annotation combines with file-level info -(header or sidecar) follows its REUSE 3.3 `precedence`: - -| `Precedence` | Effective candidates | Primary | -|--------------|----------------------|---------| -| `Closest` (default) | file-level if present, else annotation | file-level wins | -| `Aggregate` | file-level ∪ annotation | file-level if present | -| `Override` | annotation if it has a license, else file-level | annotation wins; emits `source_override` on disagreement | +**Precedence (FR-003a)** — `REUSE.toml` documents are discovered at every directory +depth and consulted root-first; each document contributes exclusively its last matching +table, and consultation stops after the rootmost `override` table (verified against the +reference REUSE 6.2.0 tool). License and copyright resolve independently: + +- The rootmost `override` table (the *barrier*) suppresses file/sidecar info and every + deeper table — even for a field the table omits (an omitted field stays missing; it + never reopens suppressed sources). Shallower `aggregate` tables are still retained, + and shallower `closest` tables still serve as the per-field fallback. Emits + `source_override` on disagreement with the suppressed header. +- Every consulted `aggregate` table always contributes its licenses and copyrights. +- `closest` tables are a per-field fallback: the nearest-outward table supplying a + license (resp. copyright) fills that field only when file-level info — or, under a + barrier, nothing — provides none. A file carrying exactly one field still takes the + other's fallback. +- `.reuse/dep5` paragraphs are `aggregate` contributors; the last matching paragraph + governs a path. `REUSE.toml` and `.reuse/dep5` are mutually exclusive — their + coexistence, like any malformed document (bad TOML, `version` other than 1, missing + `path`, invalid `precedence`, invalid license expression), fails the scan before any + write, naming the document. `candidate_licenses()` is the single precedence-aware resolver both `classify` and -`reconcile` consult, so the rule is applied in exactly one place. `.reuse/dep5` carries no -`precedence` and is treated as `Override`. +`reconcile` consult, so the rule is applied in exactly one place. `REUSE.toml` path +patterns use the REUSE grammar (`*` never crosses `/`, `**` does, only `\`-escapes are +special, `?[]{} ` are literal); dep5 `Files:` patterns are shell-style (`*` crosses `/`). **HeaderBlock** @@ -186,7 +201,17 @@ The per-file join of declared vs actual, with classification (FR-004). | `conflict` | optional `RuleConflict` | Set when equal-specificity rules matched (FR-022). | **DriftClass** (FR-004) — exhaustive, mutually exclusive: -`Compliant` | `WrongLicense{declared, actual}` | `MissingHeader` | `Uncovered` | `Excluded` | `Unreadable` +`Compliant` | `WrongLicense{declared, actual}` | `CopyrightMismatch{declared, actual}` | `MissingHeader` | `Uncovered` | `Excluded` | `Unreadable` + +License equality joins **all** effective expressions as one `AND` expression and +compares once: a matching candidate never hides an additional license. Copyright is +compared separately against the policy (`preserve` imposes nothing; `add` needs +existing notices plus the requested normalized notice; `replace` needs exactly it): +a license match with an unsatisfied copyright policy is `CopyrightMismatch`, while a +license mismatch keeps the copyright mismatch as a `copyright_mismatch` diagnostic +next to `WrongLicense`. An unresolved rule conflict has no single declared intent; +its `declared` names the tied expressions descriptively (never a fabricated token) +and the conflict stays structured in `conflict` plus a `rule_conflict` warning. `Unreadable` (FR-025) covers files that cannot be safely parsed or written (e.g. non-UTF-8); the tool never byte-edits them. @@ -210,9 +235,21 @@ Tracks referenced identifiers vs present texts in `LICENSES/` (FR-014, FR-017, F | Field | Type | Notes | |-------|------|-------| | `referenced` | set of SPDX id | Every identifier used anywhere in the repo/config. | -| `present` | set of SPDX id | Texts found under `LICENSES/`. | +| `present` | set of SPDX id | Recognized texts under root `LICENSES/` (`.txt`/`.md` suffixed, or a bare known id — the bare form satisfies presence but strict lint reports its missing extension). Symlinks never count. | | `missing` | derived set | `referenced − present` → reported; standard ids materializable from the embedded bundle offline; `LicenseRef-*` scaffolded as placeholders. The `add-license` command (FR-029) materializes this set (or an explicit subset) into `LICENSES/`. | | `bundled` | set of SPDX id | Identifiers whose text is embedded in the binary. | +| `unused` | list of SPDX id | Present but never referenced — a project-wide lint finding only, never a selected-file policy failure. | +| `unrecognized` | list of path+reason | `LICENSES/` entries naming no recognizable license (bad suffix, unknown id, legacy `+` spelling) — lint finding. | +| `missing_extension` | list of SPDX id | Recognized ids in extensionless files — lint finding; still satisfy presence for policy and materialization. | +| `unreadable` | list of path+reason | Recognized texts that are not readable UTF-8 — validation is incomplete for them, never a pass and never a proven violation. | + +Duplicate ids under several filenames make the inventory ambiguous and fail before +use (as in the reference tool). The scan keeps two selected-scope reference sets: +`actual_referenced_ids` (effective file + snippet expressions, excluding suppressed +values and unused config rules) feeds REUSE inventory; `desired_referenced_ids` +(winning intents of evaluated files only) joins it for the policy `check` text +scope. `apply` materializes its projected post-apply state only — never stale +replaced licenses or unmatched rules — and never deletes texts. --- @@ -224,8 +261,19 @@ The computed result of a `check` (read-only) or `apply` (writing) run. |-------|------|-------| | `files` | list of `FileLicensingState` | Per-file classification. | | `changes` | list of `FileChange` | For `apply`: before/after per file; for `check`: would-be changes. | -| `warnings` | list of `Warning` | Contradictions (FR-020), rule conflicts (FR-022), missing texts, `source_override` (FR-003a), encoding skips (FR-025). | -| `summary` | `{ pass, partial, counts }` | `counts` holds per-`DriftClass` totals **plus** `conflicts` and `contradictions`; `partial` (FR-021) and `pass` (FR-012a, SC-009) live here too. Drives the exit code. This is the canonical shape; `report.schema.json` matches it. | +| `diagnostics` | list of `Diagnostic` | Contradictions (FR-020), rule conflicts (FR-022), missing texts, `source_override` (FR-003a), encoding skips (FR-025). Sorted by path, then code. | +| `writes` | list of write records | Independent of file states: one record per planned/applied/failed/blocked write with destination, kind, outcome, covered files, and exact text for text writes (FR-021). | +| `summary` | `{ pass, partial, complete, before_pass, projected_pass, counts }` | `counts` holds per-`DriftClass` totals **plus** `conflicts` and `contradictions`; `partial` (FR-021) and `pass` (FR-012a, SC-009) live here too. `complete` is false when final verification did not run; `before_pass` is the pre-apply gate; dry-run `pass` means `projected_pass`. Drives the exit code. This is the canonical shape; `report.schema.json` matches it. | + +**PlannedWrite / write record** + +| Field | Type | Notes | +|-------|------|-------| +| `path` | path | Destination written (the document itself for metadata patches). | +| `kind` | enum `{ source, sidecar, reuse_toml, license_text, config }` | What the write mutates (FR-021). | +| `status` | enum `{ planned, applied, failed, blocked }` | Dry-run previews as `planned`; converged runs emit no record (`unchanged` is absence). | +| `affected_files` | list of paths | Selected files the write covers (the assets behind a metadata patch). | +| `before_text` / `after_text` | optional text | Exact bytes as text for text being written; absent for blocked writes with no known result. | **FileChange** @@ -249,4 +297,4 @@ The computed result of a `check` (read-only) or `apply` (writing) run. | FileLicensingState, DriftClass | FR-003, FR-004, FR-012a, FR-025 | | LicenseTextInventory | FR-014, FR-015, FR-017, FR-028, FR-029 | | ReconciliationPlan/Report, FileChange | FR-006, FR-007, FR-012, FR-013, FR-020, FR-021, FR-024, SC-009, SC-010 | -| Scan cache (fingerprint key) | FR-023, SC-011 | +| Stateless scan (no cache) | FR-023, SC-011 | diff --git a/specs/001-declarative-license-headers/spec.md b/specs/001-declarative-license-headers/spec.md index e4248b7..fa61d1e 100644 --- a/specs/001-declarative-license-headers/spec.md +++ b/specs/001-declarative-license-headers/spec.md @@ -152,7 +152,7 @@ The tool produces output that conforms to the REUSE specification (SPDX identifi - **FR-004**: The system MUST report drift per file, distinguishing at least: compliant, wrong-license (declared ≠ actual), missing header, uncovered-by-rules, explicitly-excluded, and unreadable (cannot be safely parsed or written, e.g. non-UTF-8). - **FR-005**: The system MUST compare license expressions by parsed canonical form — equal up to operator commutativity, associativity, whitespace, parenthesization, and case-insensitive identifiers (so `Apache-2.0 OR MIT` equals `MIT OR Apache-2.0`) — rather than by raw string match. Full logical/distributive equivalence (e.g. `A AND (B OR C)` ≡ `(A AND B) OR (A AND C)`) is explicitly out of scope for v1. - **FR-006**: The system MUST be able to reconcile (apply) files so their headers match declared intent, including writing missing headers and correcting incorrect ones. -- **FR-007**: The system MUST support both additive reconciliation (add declared content, retain existing) and destructive reconciliation (replace conflicting content with declared content), selectable by the maintainer. When no mode is specified, `apply` MUST default to destructive reconciliation of the license identifier (replacing it to match the configuration); additive reconciliation MUST be explicitly opted into. +- **FR-007**: The system MUST support both additive reconciliation (add declared content, retain existing) and destructive reconciliation (replace conflicting content with declared content), selectable by the maintainer. When no mode is specified, `apply` MUST default to destructive reconciliation of the license identifier (replacing it to match the configuration); additive reconciliation MUST be explicitly opted into. Destructive replacement edits license values at their detected byte spans and refuses — reporting `unfixable` rather than writing — when a tag sits in program text instead of a comment, or when the target block carries distinct license records no single replacement can order. - **FR-008**: The system MUST allow targeting which existing header content is replaced versus preserved, including selecting a specific header among multiple rather than only the first. - **FR-009**: The system MUST, by default, preserve copyright/authorship information when replacing license content, and MUST manage copyright additively unless explicitly told to replace it. - **FR-010**: The system MUST allow comment-style associations to be defined in configuration for arbitrary file extensions and exact filenames, and these associations MUST persist across runs without per-file flags. @@ -161,15 +161,19 @@ The tool produces output that conforms to the REUSE specification (SPDX identifi - **FR-012a**: The enforcement gate MUST treat an uncovered file (matched by no rule, no explicit exclusion, and no default) as a failure, so the gate fails until every file is covered by a rule, an explicit exclusion, or the repository default. - **FR-013**: The system MUST support evaluating only a supplied subset of files (e.g., staged or changed files) rather than always scanning the whole repository. - **FR-014**: The system MUST produce output conforming to the REUSE specification (SPDX header identifiers, a `LICENSES/` tree of referenced texts, and a recognized mechanism for non-annotatable files) so existing REUSE consumers remain compatible. -- **FR-015**: The system MUST cover non-annotatable/binary files via a REUSE-compatible mechanism so they are still subject to enforcement, without byte-editing the asset. `apply` writes a `.license` sidecar by default, or appends a `REUSE.toml` annotation when configured (`[output] non_annotatable = "reuse-toml"`, or `--non-annotatable`). When a file is already *correctly* covered out-of-band, `apply` writes nothing. When it is covered but the recorded license is wrong, `apply` reconciles it where the coverage lives — rewriting the `REUSE.toml` annotation in place, or appending a more-specific exact-path annotation when only a glob matches (REUSE 3.3 last match wins); legacy `.reuse/dep5` coverage is reported for manual fixup rather than rewritten. All `REUSE.toml` writes MUST be idempotent. +- **FR-015**: The system MUST cover non-annotatable/binary files via a REUSE-compatible mechanism so they are still subject to enforcement, without byte-editing the asset. `apply` writes a `.license` sidecar by default, or appends a `REUSE.toml` annotation when configured (`[output] non_annotatable = "reuse-toml"`, or `--non-annotatable`). When a file is already *correctly* covered out-of-band, `apply` writes nothing. When it is covered but the recorded license is wrong, `apply` reconciles it where the coverage lives — appending a superseding exact-path `REUSE.toml` annotation (REUSE 3.3 last match wins), with `precedence = "override"` when the file sits behind an `override` barrier; the existing document is never rewritten in place. Legacy `.reuse/dep5` coverage is reported for manual fixup rather than rewritten. All `REUSE.toml` writes MUST be idempotent. - **FR-016**: The system MUST allow paths to be explicitly excluded from coverage, and MUST distinguish explicit exclusions from accidental non-coverage in reporting. - **FR-017**: The system MUST report referenced-but-missing license texts and MUST be able to supply the missing text. It MUST ship a bundled set of standard SPDX license texts and use them offline by default (no network access required for known identifiers). Fetching a text over the network MUST be limited to identifiers absent from the bundle and MUST require explicit opt-in; custom `LicenseRef-` licenses are scaffolded as placeholders for the maintainer to fill in. -- **FR-018**: The system MUST be able to bootstrap an initial declarative configuration from an existing project's current state (including an existing `REUSE.toml` and existing headers). +- **FR-018**: The system MUST be able to bootstrap an initial declarative configuration from an existing project's current state (including an existing `REUSE.toml` and existing headers). Generation preserves observations: exact-path rules first (root files as `./name`), extension compression only for provably uniform groups, a default only when every covered file is known, unknown files left unknown, copyright always `preserve`. The generated config is validated against every observation before writing; creation is new-by-default and refuses to overwrite without `--force`. - **FR-019**: The system MUST insert headers in a position that respects required-first lines such as shebangs and encoding declarations. - **FR-020**: The system MUST warn when an operation produces an internally contradictory result (e.g., additive mode leaving two different declared licenses on one file). -- **FR-021**: The system MUST report partial-apply outcomes clearly, identifying which files were changed and which were not when reconciliation cannot complete fully. +- **FR-021**: The system MUST report partial-apply outcomes clearly, identifying which files were changed and which were not when reconciliation cannot complete fully. `apply` plans every mutation as an independent write record (destination, kind, expected bytes, covered files) before mutating anything; dry-run previews the same records without executing or fetching. Missing bundled license texts are planned writes; missing custom texts and unfetched downloads are explicit blockers that fail the gate. One observation triple `(changed, operational_failure, violations)` drives both the summary and the exit: partial means the tool itself failed partway, while writes that all succeed but leave drift are violations. - **FR-022**: The system MUST surface rule conflicts (equal-specificity matches) to the maintainer rather than resolving them silently and invisibly. -- **FR-023**: The system MUST key any scan cache on a fingerprint that includes file content, the effective configuration (rules, default, comment-style registry, exclusions), and the tool version, so that a cached classification is never reused when any input that could change that classification has changed. A cache hit MUST be observationally identical to a cold computation. +- **FR-023**: Scans are stateless: every run classifies from current bytes, with no + scan cache. There is no cached classification to go stale, so repeated runs + over unchanged inputs are observationally identical by construction. The + `--cache` / `--no-cache` flags remain only as deprecated no-ops and MUST NOT + create files. - **FR-024**: Destructive `apply` MUST refuse to modify files when the working tree has uncommitted changes unless explicitly overridden (`--allow-dirty`), and every file modification MUST be performed atomically (write to a temporary file, then rename over the original) so that an interruption never leaves a source file partially written. - **FR-025**: The system MUST detect files that are not valid UTF-8 and MUST NOT attempt byte-offset header insertion on them; such files are classified `Unreadable`, reported with a warning, and counted as a gate failure. (Transcoding non-UTF-8 files is out of scope for v1.) - **FR-026**: When writing or modifying a header the system MUST preserve the file's existing newline convention (LF vs CRLF) and MUST NOT introduce mixed line endings. @@ -196,12 +200,12 @@ The tool produces output that conforms to the REUSE specification (SPDX identifi - **SC-003**: The drift check correctly classifies files with zero false "compliant" results — no file that disagrees with declared intent is reported as compliant — across a representative test corpus that includes compliant, wrong-license, missing, uncovered, and excluded files. - **SC-004**: Destructive reconciliation never destroys copyright/authorship information unless explicitly instructed; in a corpus of files containing both license and copyright lines, 100% of copyright lines survive a license-only replacement. - **SC-005**: A maintainer can make a previously unrecognized file type (e.g., `*.pkl`) fully managed by adding one configuration entry, after which headers on those files round-trip (write, then recognize) across runs with no per-file flags. -- **SC-006**: The check runs fast enough to be unobtrusive in a commit hook: a changed-file check completes in well under one second; a full-repository scan of a ~10,000-file repository completes in under **1 second warm** (repeat/local, populated cache) and under **3 seconds cold** (empty cache, e.g. a fresh CI checkout) on a 4-core 2020-era runner — and remains comfortably faster than the existing REUSE tool on the same repository and hardware. +- **SC-006**: The check runs fast enough to be unobtrusive in a commit hook: a changed-file check completes in well under one second; a full-repository scan of a ~10,000-file repository completes in under **1 second on repeat runs** and under **3 seconds on a first run** (e.g. a fresh CI checkout) on a 4-core 2020-era runner — and remains comfortably faster than the existing REUSE tool on the same repository and hardware. There is no scan cache; repeat runs are fast because classification is parallel and content-driven, not because verdicts persist. - **SC-007**: A repository reconciled by the tool passes a standard REUSE-specification compliance check. - **SC-008**: An existing REUSE project can be migrated by bootstrapping a configuration from its current state such that projecting that configuration reproduces the project's existing licensing without manual rewriting of every rule. The bootstrap MUST **generalize** rather than enumerate: on the reference REUSE fixture, the generated config covers the repository using substantially fewer rules than files (target: rule count ≤ 25% of covered file count), preferring extension/glob rules over per-file exact-path rules wherever a broader rule reproduces the same projection. - **SC-009**: The enforcement gate returns an unambiguous pass/fail result so that a drifted change is blocked and a compliant change is not, with output that names the offending files and the declared-vs-actual difference. - **SC-010**: No source file is ever left partially written or corrupted by `apply`: under an induced failure (process killed mid-run, a file made unwritable, a non-UTF-8 file encountered), every file is either fully reconciled or untouched, and the run reports exactly which files changed and which did not (FR-021, FR-024, FR-025). -- **SC-011**: After any change to the configuration, rule set, comment-style registry, or tool version, a cached run yields the same classification a cold run would — no file is reported `Compliant` on the basis of a stale cache entry (FR-023). +- **SC-011**: After any change to the configuration, rule set, comment-style registry, or tool version, a run yields the same classification a fresh computation would — no file is reported `Compliant` on the basis of a stale entry, because no entries persist between runs (FR-023). ## Assumptions @@ -210,7 +214,7 @@ The tool produces output that conforms to the REUSE specification (SPDX identifi - **License vs copyright handling differ by default**: License identifiers are managed declaratively and may be replaced; copyright/authorship lines are preserved and accumulated additively unless explicitly overridden, because authorship is not something a path-based rule should silently erase. - **Scope of the unit of work is a single repository** working tree; the primary interface is a command-line tool suitable for direct use, commit hooks, and CI. Multi-repository orchestration is out of scope for the first version. - **File selection defaults to tracked files**: The repository's version-controlled file set defines coverage by default; untracked/ignored files are not enforced unless configured. -- **"Fast" means non-blocking in the dev loop**: The concrete bar is "unnoticeable in a commit hook" for changed-file runs and a full scan of a ~10,000-file repository in under 1 second (warm cache), comfortably faster than the current REUSE tool (see SC-006). +- **"Fast" means non-blocking in the dev loop**: The concrete bar is "unnoticeable in a commit hook" for changed-file runs and a full scan of a ~10,000-file repository in under 1 second on repeat runs, comfortably faster than the current REUSE tool (see SC-006). - **Existing comment-style coverage is retained**: The tool starts from at least the set of file types the current REUSE tool understands and adds user-defined associations on top, rather than reimplementing fewer. - **`REUSE.toml`/`dep5` are interop surfaces, not authoring surfaces**: out-of-band REUSE files are read for detection and compatibility (the annotation's REUSE 3.3 `precedence` decides detection when it disagrees with an in-file header — `closest` by default), but the maintainer never hand-authors licensing intent there; `license.toml` is the single declarative authoring surface. This reflects the maintainer's view that `REUSE.toml` is not designed for declarative intent. - **Single-binary distribution embeds a versioned SPDX corpus**: the embedded SPDX license list is a point-in-time snapshot; its version is surfaced (FR-028) and refreshing it is a rebuild/release concern, not a runtime fetch. diff --git a/src/cli/add_license.rs b/src/cli/add_license.rs index f56c6f8..397e93b 100644 --- a/src/cli/add_license.rs +++ b/src/cli/add_license.rs @@ -2,9 +2,16 @@ //! from the embedded bundle, offline (FR-017, FR-029; US5). //! //! This is the offline analog of REUSE's `download`: because the SPDX corpus is embedded, -//! the operation is a copy from the bundle (or a `LicenseRef-*` placeholder scaffold), -//! never a network fetch. It writes **only** under `LICENSES/` — it never modifies a source -//! file or `license.toml`, and therefore does not require a clean working tree. +//! the operation is a copy from the bundle, never a network fetch — unless the caller +//! passes `--allow-network`, which permits downloading valid-but-unbundled standard +//! texts with the system `curl` binary into owned temporary storage. It writes **only** +//! under `LICENSES/` — it never modifies a source file or `license.toml`, and therefore +//! does not require a clean working tree. +//! +//! Every requested identifier is validated before any directory is created or any +//! subprocess is spawned: unknown ids and path escapes are usage errors (exit 2). +//! Syntactically valid `LicenseRef-*` ids are reported as required local texts — +//! never downloaded, and never scaffolded with placeholder prose. use std::collections::BTreeSet; @@ -12,14 +19,14 @@ use super::{AddLicenseArgs, Format}; use crate::config::LicensingConfiguration; use crate::engine::Engine; use crate::error::{ExitCode, LicetError, Result}; -use crate::reuse::inventory::{self, LicenseTextInventory}; +use crate::reuse::inventory::{self, LicenseTextInventory, ValidatedId, validate_materialize_id}; +use crate::reuse::{atomic_write, read_expected_for_write}; use crate::spdx; -use crate::walk::cache::ScanCache; -use crate::walk::{Selection, discover_root}; +use crate::walk::{Purpose, Selection, discover_root, prepare}; pub fn run(args: AddLicenseArgs) -> Result { let cwd = std::env::current_dir()?; - let root = discover_root(&cwd); + let (root, _) = discover_root(&cwd)?; // Flag contract (FR-029): exactly one of or --all. if args.ids.is_empty() && !args.all { @@ -34,21 +41,50 @@ pub fn run(args: AddLicenseArgs) -> Result { )); } - // Resolve the target id set: `--all` scans config + headers for referenced ids; an - // explicit list targets exactly those ids (whether or not they are referenced anywhere). - let targets: BTreeSet = if args.all { - let config_text = std::fs::read_to_string(&args.config).unwrap_or_default(); - let config = LicensingConfiguration::from_toml(&config_text).unwrap_or_default(); - let mut cache = ScanCache::disabled(); - let engine = Engine::new(root.clone(), &config, &config_text); - engine - .scan(&Selection::FullTree, &mut cache)? - .referenced_ids + // Resolve the target id set: `--all` scans the union of desired and + // actual references; config errors propagate (exit 2), never swallowed. + // An explicit list targets exactly those ids (whether or not they are + // referenced anywhere). + let raw_targets: BTreeSet = if args.all { + let config_arg = match &args.config { + Some(p) if p.is_absolute() => p.clone(), + Some(p) => cwd.join(p), + None => root.join("license.toml"), + }; + let prep = prepare( + &cwd, + &config_arg, + &Selection::FullTree, + Purpose::Policy, + true, + )?; + let config_text = prep.config_text.clone(); + let config = LicensingConfiguration::from_toml(&config_text)?; + let engine = Engine::new(root.clone(), &config, prep.snapshot, true); + engine.scan(&prep.paths)?.referenced_ids } else { args.ids.iter().cloned().collect() }; - let before = LicenseTextInventory::compute(&root, &targets); + // Validate the whole requested list (canonicalizing standard-id spelling) before + // creating any directory or spawning any subprocess. An invalid id is a usage + // error even when `--allow-network` is given. + let mut validated: Vec = Vec::with_capacity(raw_targets.len()); + for id in &raw_targets { + validated.push(validate_materialize_id(id).map_err(|e| LicetError::Config(e.to_string()))?); + } + let targets: BTreeSet = validated + .iter() + .map(|v| match v { + ValidatedId::Bundled { canonical } | ValidatedId::Fetchable { canonical } => { + canonical.clone() + } + ValidatedId::CustomRef { id } => id.clone(), + }) + .collect(); + + let worktree = crate::walk::Snapshot::Worktree { root: root.clone() }; + let before = LicenseTextInventory::compute(&worktree, &targets)?; let already_present: Vec = targets .iter() .filter(|id| before.present.contains(*id)) @@ -56,8 +92,56 @@ pub fn run(args: AddLicenseArgs) -> Result { .collect(); let res = inventory::materialize(&root, &targets)?; - let after = LicenseTextInventory::compute(&root, &targets); - let success = res.still_missing.is_empty(); + + // Optional network fetch for valid-but-unbundled standard ids. LicenseRef-* + // ids are never fetched: custom texts must be supplied by the maintainer. + // Failures leave the destination untouched and keep the id missing (exit 1). + let mut fetched: Vec = Vec::new(); + let mut fetch_failures: Vec<(String, String)> = Vec::new(); + let mut still_missing = res.still_missing; + if args.allow_network { + let mut remaining = Vec::new(); + for id in std::mem::take(&mut still_missing) { + let fetchable = validated + .iter() + .any(|v| matches!(v, ValidatedId::Fetchable { canonical } if canonical == &id)); + if !fetchable || before.present.contains(&id) { + remaining.push(id); + continue; + } + match inventory::fetch_text_via_curl(&id) { + Ok(bytes) => { + let rel = std::path::Path::new("LICENSES").join(format!("{id}.txt")); + match read_expected_for_write(&root.join(&rel)) { + Ok(None) => match atomic_write(&root, &rel, None, &bytes) { + Ok(()) => fetched.push(id), + Err(e) => { + fetch_failures.push((id, format!("install failed: {e}"))); + remaining.push(fetch_failures.last().unwrap().0.clone()); + } + }, + Ok(Some(_)) => { + // Appeared concurrently; treat as present. + } + Err(e) => { + fetch_failures.push((id, format!("cannot verify destination: {e}"))); + remaining.push(fetch_failures.last().unwrap().0.clone()); + } + } + } + Err(e) => { + fetch_failures.push((id.clone(), format!("download failed: {e}"))); + remaining.push(id); + } + } + } + still_missing = remaining; + } + + let after = LicenseTextInventory::compute(&worktree, &targets)?; + let mut written = res.written; + written.extend(fetched.iter().cloned()); + let success = still_missing.is_empty(); match args.format { Format::Json => { @@ -67,43 +151,60 @@ pub fn run(args: AddLicenseArgs) -> Result { missing: after.missing.iter().cloned().collect(), bundled_available: after.bundled_available.iter().cloned().collect(), spdx_list_version: spdx::spdx_list_version().to_string(), + unused: after.unused.clone(), + unrecognized: after.unrecognized.iter().map(|u| u.path.clone()).collect(), + missing_extension: after.missing_extension.clone(), }; let report = serde_json::json!({ - "version": 1, + "version": 2, "command": "add-license", "exit_code": if success { 0 } else { 1 }, - "summary": { "pass": success, "counts": {} }, + "summary": { "pass": success, "complete": true, "counts": {} }, "files": [], "license_texts": texts, }); - println!("{}", serde_json::to_string_pretty(&report).unwrap()); + // Serializing a `serde_json::Value` cannot fail; the fallback keeps + // a serialization bug from panicking instead of reporting. + let body = serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string()); + super::emit_stdout(&format!("{body}\n"))?; } Format::Human => { - println!( - "Materialized {} license text(s) into LICENSES/ (SPDX list {}):", - res.written.len(), + // Built as one document so stdout goes through the shared + // emitter (quiet on a closed pipe instead of panicking). + let mut human = format!( + "Materialized {} license text(s) into LICENSES/ (SPDX list {}):\n", + written.len(), spdx::spdx_list_version() ); - for id in &res.written { - println!(" + {id}"); + for id in &written { + human.push_str(&format!(" + {id}\n")); } if !already_present.is_empty() { - println!("Already present (skipped):"); + human.push_str("Already present (skipped):\n"); for id in &already_present { - println!(" = {id}"); + human.push_str(&format!(" = {id}\n")); } } - if !res.still_missing.is_empty() { - println!("Unavailable offline:"); - for id in &res.still_missing { - let why = if args.allow_network { - "absent from bundle — network fetch is unavailable in this hermetic build" + if !still_missing.is_empty() { + human.push_str("Unavailable:\n"); + for id in &still_missing { + let why = if spdx::is_license_ref(id) { + format!("custom LicenseRef — add LICENSES/{id}.txt manually") + } else if let Some((_, err)) = fetch_failures.iter().find(|(fid, _)| fid == id) + { + err.clone() + } else if args.allow_network { + format!( + "absent from bundle — download failed ({})", + inventory::download_url(id) + ) } else { - "absent from bundle — needs --allow-network" + "absent from bundle — needs --allow-network".to_string() }; - println!(" ! {id} ({why})"); + human.push_str(&format!(" ! {id} ({why})\n")); } } + super::emit_stdout(&human)?; } } diff --git a/src/cli/apply.rs b/src/cli/apply.rs index 5ec54b5..43d39ff 100644 --- a/src/cli/apply.rs +++ b/src/cli/apply.rs @@ -1,9 +1,9 @@ //! `apply` — reconcile to declared intent (FR-006..FR-009, FR-021, FR-024; US2). -use std::path::Path; +use std::path::{Path, PathBuf}; use super::{ApplyArgs, Format}; -use crate::comment::{CommentResolver, render_sidecar}; +use crate::comment::{CommentResolver, render_sidecar, render_sidecar_multi}; use crate::config::LicensingConfiguration; use crate::detect; use crate::domain::{ActualSource, ChangeMode, DriftClass, FileChange, NonAnnotatableStrategy}; @@ -11,33 +11,60 @@ use crate::engine::Engine; use crate::error::{ExitCode, LicetError, Result}; use crate::reconcile::copyrights_to_write; use crate::report::render::render_human; -use crate::report::{Report, Warning}; +use crate::report::{Diagnostic, Report}; use crate::reuse::oob::{self, OutOfBand}; use crate::reuse::{self, inventory}; -use crate::walk::{Selection, discover_root}; +use crate::walk::{self, Purpose, Selection, Snapshot, discover_root}; pub fn run(args: ApplyArgs) -> Result { let cwd = std::env::current_dir()?; - let root = discover_root(&cwd); - let config_text = std::fs::read_to_string(&args.common.config) - .map_err(|e| LicetError::Config(format!("cannot read config: {e}")))?; - let config = LicensingConfiguration::from_toml(&config_text)?; + let (root, _) = discover_root(&cwd)?; let selection = args.common.selection()?; + let config_arg = args.common.config_arg(&cwd, &root); + let prep = walk::prepare(&cwd, &config_arg, &selection, Purpose::Apply, false)?; + let config = LicensingConfiguration::from_toml(&prep.config_text)?; - // Dirty-tree guard (FR-024, SC-010) — skip on dry-run. - if !args.dry_run && !args.allow_dirty && is_dirty(&root) { - return Err(LicetError::Config( - "working tree has uncommitted changes; commit/stash first or pass --allow-dirty" - .to_string(), - )); + // `apply --staged` evaluates the staged *path set* but edits working-tree + // files; the index is never staged into. Say so explicitly. + if matches!(selection, Selection::Staged) { + eprintln!( + "note: apply --staged evaluates staged paths but edits working-tree files; \ + edits are not staged" + ); } - let mut cache = super::check::open_cache(&args.common, &root, &config_text); - let engine = Engine::new(root.clone(), &config, &config_text); - let scan = engine.scan(&selection, &mut cache)?; + // Dirty-tree guard (FR-024, SC-010) — skip on dry-run. A Git launch/status + // failure never means "clean", and outside a repository there is no Git + // undo guarantee, so both require explicit `--allow-dirty`. + if !args.dry_run && !args.allow_dirty { + match tree_state(&root)? { + TreeState::Clean => {} + TreeState::Dirty => { + return Err(LicetError::Config( + "working tree has uncommitted changes; commit/stash first or pass \ + --allow-dirty" + .to_string(), + )); + } + TreeState::NoGitGuarantee => { + return Err(LicetError::Config( + "not a git repository: no undo guarantee, pass --allow-dirty to proceed" + .to_string(), + )); + } + } + } + + super::check::warn_deprecated_cache_flags(&args.common); + let snapshot_label = prep.snapshot.source().as_str().to_string(); + let engine = Engine::new(root.clone(), &config, prep.snapshot, true); + let scan = engine.scan(&prep.paths)?; + // Freeze the evaluated path set now: post-apply verification re-reads these + // same paths and must not recompute a set that apply itself changed. + let frozen = prep.paths.clone(); let resolver = CommentResolver::new(&config); - let oob = OutOfBand::load(&root); + let oob = OutOfBand::load_snapshot(&engine.snapshot)?; let mode = if args.additive { ChangeMode::Additive } else { @@ -49,97 +76,215 @@ pub fn run(args: ApplyArgs) -> Result { .map(NonAnnotatableStrategy::from) .unwrap_or(config.non_annotatable); + use crate::domain::{ExecutedWrite, PlannedWrite, WriteKind, WriteStatus}; + + /// One planned single-file write plus the bookkeeping to file its outcome. + struct FileWriteOp { + change_idx: usize, + /// File path used in write-failure diagnostics (the covered file, not + /// the sidecar document actually written for sidecar coverage). + warn_path: String, + write: PlannedWrite, + } + /// One planned annotation request plus the bookkeeping to file its outcome. + struct TomlReq { + change_idx: usize, + warn_path: String, + rel: String, + license: String, + copyrights: Vec, + } + let mut changes: Vec = Vec::new(); - // Warnings produced by the apply pass itself (contradictions, write failures, …). - // Detection warnings are taken from the final re-scan so they reflect on-disk state + let mut file_ops: Vec = Vec::new(); + let mut toml_reqs: Vec = Vec::new(); + // Diagnostics produced by the apply pass itself (contradictions, write failures, …). + // Detection diagnostics are taken from the final re-scan so they reflect on-disk state // (a pre-write `encoding_skipped`, say, must not linger after a sidecar fixes it). - let mut warnings: Vec = Vec::new(); - let mut any_failure = false; - let mut any_success = false; + let mut warnings: Vec = Vec::new(); + // Operational failures: the tool itself could not do its job (unreadable + // inputs, refused plans, failed writes). Remaining declaration drift and + // unfixable entries are violations, not operational failures. + let mut operational_failure = false; + // Plan entries that leave drift by design (unfixable, manual fixup): + // dry-run projects them as gate failures. + let mut blocked_by_design = false; + // Gate state before apply ran (summary.before_pass). + let before_pass = + crate::report::counts_pass(&crate::report::count_drift(&scan.states, &scan.warnings)); + + // Files the snapshot could not be read for are gate failures, not edit targets. + let unreadable: std::collections::HashSet<&str> = scan + .warnings + .iter() + .filter(|w| w.code == "read_error") + .filter_map(|w| w.path.as_deref()) + .collect(); for state in &scan.states { - // Writable: a license header can be established/fixed. `Unreadable` (a binary - // asset with no sidecar) is now writable too — it is coverable out-of-band. + // Writable: a license header can be established/fixed, and copyright + // intent applies even when the license already matches (FR-007). + // `Unreadable` (a binary asset with no sidecar) is now writable too — + // it is coverable out-of-band. let writable = matches!( state.drift, - DriftClass::MissingHeader | DriftClass::WrongLicense { .. } | DriftClass::Unreadable + DriftClass::MissingHeader + | DriftClass::WrongLicense { .. } + | DriftClass::CopyrightMismatch { .. } + | DriftClass::Unreadable ); if !writable { continue; } + let rel_str = state.path.to_string_lossy().replace('\\', "/"); + if unreadable.contains(rel_str.as_str()) { + continue; + } let intent = match &state.declared_intent { Some(i) => i, None => continue, }; let abs = root.join(&state.path); - let rel_str = state.path.to_string_lossy().replace('\\', "/"); - let has_sidecar = detect::sidecar_path(&abs).exists(); + // Sidecar presence comes from the evaluated snapshot, so a staged sidecar + // differing from the working copy is honored. + let sidecar_rel = detect::sidecar_path(&state.path); + let has_sidecar = match engine.snapshot.read(&sidecar_rel) { + Ok(b) => b.is_some(), + Err(e) => { + operational_failure = true; + warnings.push(Diagnostic { + code: "partial_apply".to_string(), + path: Some(rel_str.clone()), + message: format!("cannot read sidecar for edit: {e}"), + }); + continue; + } + }; let is_binary = matches!(state.drift, DriftClass::Unreadable); let in_file = !has_sidecar && !is_binary; // Annotatable text (a resolvable comment style, no sidecar, readable) gets an - // in-file header; everything else is covered out-of-band so the asset is never + // in-file header — unless out-of-band metadata already governs the file, in + // which case the fix belongs to the document: an in-file edit would be + // suppressed (override), shadowed, or a second record for the same file + // (FR-003a). Provenance picks the destination before any comment syntax. + // Everything else is covered out-of-band so the asset is never // byte-edited (FR-015). - if let Some(style) = resolver.resolve(&state.path).filter(|_| in_file) { + let oob_governs = matches!( + state.actual.detected_source, + Some(ActualSource::ReuseToml | ActualSource::Dep5) + ) || state + .actual + .out_of_band + .as_ref() + .is_some_and(|e| e.suppresses_file); + if let Some(style) = resolver + .resolve(&state.path) + .filter(|_| in_file && !oob_governs) + { // Re-read full content for an accurate edit (engine only read the head). - let content = match std::fs::read_to_string(&abs) { + // The exact bytes double as the expected-content guard for replacement. + let old_bytes = match std::fs::read(&abs) { + Ok(b) => b, + Err(e) => { + operational_failure = true; + warnings.push(Diagnostic { + code: "partial_apply".to_string(), + path: Some(rel_str.clone()), + message: format!("cannot read for edit: {e}"), + }); + continue; + } + }; + let content = match String::from_utf8(old_bytes.clone()) { Ok(c) => c, Err(_) => { - any_failure = true; + operational_failure = true; + warnings.push(Diagnostic { + code: "partial_apply".to_string(), + path: Some(rel_str.clone()), + message: "file is not valid UTF-8; cannot edit in place".to_string(), + }); continue; } }; // Re-detect against full content so byte ranges are correct for the whole file. let actual = detect::detect(&state.path, content.as_bytes(), None, &oob); - let plan = crate::reconcile::plan_file( + let plan = match crate::reconcile::plan_file( &content, &actual, intent, &style, mode, args.target_header, - ); + ) { + Ok(plan) => plan, + // A refused plan writes nothing: `unfixable` needs a human + // (the remaining drift is a violation, not a tool failure), + // an inconsistent target is an operational failure. Either way + // the file is recorded as not applied (FR-007). + Err(e) => { + let unfixable = matches!(e.kind, crate::reconcile::PlanErrorKind::Unfixable); + if unfixable { + blocked_by_design = true; + } else { + operational_failure = true; + } + warnings.push(Diagnostic { + code: if unfixable { + "unfixable".to_string() + } else { + "partial_apply".to_string() + }, + path: Some(rel_str.clone()), + message: e.message, + }); + changes.push(FileChange { + path: state.path.clone(), + mode, + target_header: args.target_header, + wrote_header: false, + preserved_copyrights: 0, + applied: false, + }); + continue; + } + }; if plan.contradiction { - warnings.push(Warning { - kind: "contradiction".to_string(), + warnings.push(Diagnostic { + code: "contradiction".to_string(), path: Some(rel_str.clone()), message: "additive apply left two contradictory licenses".to_string(), }); } - let applied = if args.dry_run { - false - } else if let Some(new_content) = &plan.new_content { - match reuse::atomic_write(&abs, new_content) { - Ok(()) => { - any_success = true; - true - } - Err(e) => { - any_failure = true; - warnings.push(Warning { - kind: "partial_apply".to_string(), - path: Some(rel_str.clone()), - message: format!("write failed: {e}"), - }); - false - } - } - } else { - false - }; - + let change_idx = changes.len(); changes.push(FileChange { path: state.path.clone(), mode: plan.mode, target_header: plan.target_header, wrote_header: plan.wrote_header, preserved_copyrights: plan.preserved_copyrights, - applied, + applied: false, }); + // The write itself waits for the execute phase below; a no-op + // plan (already handled) simply records `applied: false`. + if let Some(new_content) = plan.new_content { + file_ops.push(FileWriteOp { + change_idx, + warn_path: rel_str.clone(), + write: PlannedWrite { + path: state.path.clone(), + kind: WriteKind::Source, + before: Some(old_bytes), + after: Some(new_content.into_bytes()), + affected_files: vec![state.path.clone()], + }, + }); + } continue; } @@ -148,14 +293,16 @@ pub fn run(args: ApplyArgs) -> Result { // Legacy `.reuse/dep5` is read for detection but never rewritten in place (its // Debian-paragraph format is deprecated in REUSE 3.x); flag it for manual fixup. if matches!(state.actual.detected_source, Some(ActualSource::Dep5)) { - warnings.push(Warning { - kind: "source_override".to_string(), + // Manual fixup: the remaining drift is a violation for the gate, + // not an operational failure of this run. + blocked_by_design = true; + warnings.push(Diagnostic { + code: "source_override".to_string(), path: Some(rel_str.clone()), message: "covered by a .reuse/dep5 entry with a conflicting license; \ update that entry manually" .to_string(), }); - any_failure = true; continue; } @@ -168,12 +315,38 @@ pub fn run(args: ApplyArgs) -> Result { // corrected where it lives; in-file headers and sidecars (and a genuinely uncovered // file under `--non-annotatable reuse-toml`) are covered file-level via a sidecar // unless the configured strategy says otherwise. + // A suppressing (`override`) entry routes to its document even when + // it carries no license: any file-level record would be ignored, so a + // sidecar would be an ineffective second record (FR-003a). let via_reuse_toml = match state.actual.detected_source { Some(ActualSource::ReuseToml) => true, Some(ActualSource::Sidecar | ActualSource::Header | ActualSource::Dep5) => false, - None => matches!(strategy, NonAnnotatableStrategy::ReuseToml), + None => { + matches!(strategy, NonAnnotatableStrategy::ReuseToml) + || state + .actual + .out_of_band + .as_ref() + .is_some_and(|e| e.suppresses_file) + } }; + // Never emit a new REUSE document alongside an existing `.reuse/dep5`: + // the two are mutually exclusive, so a REUSE.toml fix next to dep5 is + // an unfixable plan entry requiring manual migration, not a write. + if via_reuse_toml && oob.has_dep5() { + blocked_by_design = true; + warnings.push(Diagnostic { + code: "unfixable".to_string(), + path: Some(rel_str.clone()), + message: "a REUSE.toml annotation cannot be added while .reuse/dep5 exists \ + (the two are mutually exclusive); migrate with `reuse convert-dep5` \ + and re-run" + .to_string(), + }); + continue; + } + // Report path: the asset for REUSE.toml coverage, else the sidecar. let change_path = if via_reuse_toml { state.path.clone() @@ -181,112 +354,444 @@ pub fn run(args: ApplyArgs) -> Result { detect::sidecar_path(&state.path) }; - let applied = if args.dry_run { - false + let change_idx = changes.len(); + changes.push(FileChange { + path: change_path, + mode: ChangeMode::Destructive, + target_header: None, + wrote_header: true, + preserved_copyrights: preserved, + applied: false, + }); + if via_reuse_toml { + toml_reqs.push(TomlReq { + change_idx, + warn_path: rel_str.clone(), + rel: rel_str.clone(), + license: intent.license_expression.clone(), + copyrights, + }); } else { - let result = if via_reuse_toml { - oob::write_annotation(&root, &rel_str, &intent.license_expression, ©rights) - .map(|w| w.modified()) + // Additive sidecar coverage preserves the old identifiers and + // notices instead of replacing them (FR-006). + let body = if mode == ChangeMode::Additive { + let mut licenses: Vec = state + .actual + .headers + .iter() + .flat_map(|h| h.license_ids.clone()) + .collect(); + if !licenses + .iter() + .any(|l| crate::spdx::expressions_equal(l, &intent.license_expression)) + { + licenses.push(intent.license_expression.clone()); + } + render_sidecar_multi(&licenses, ©rights) } else { - let body = render_sidecar(&intent.license_expression, ©rights); - reuse::atomic_write(&detect::sidecar_path(&abs), &body).map(|()| true) + render_sidecar(&intent.license_expression, ©rights) }; - match result { - Ok(changed) => { - if changed { - any_success = true; - } - changed - } + // Read the expected bytes now, at plan time: dry-run previews the + // same record the executor will guard on. + let sidecar_rel = detect::sidecar_path(&state.path); + let before = match reuse::read_expected_for_write(&root.join(&sidecar_rel)) { + Ok(expected) => expected, Err(e) => { - any_failure = true; - warnings.push(Warning { - kind: "partial_apply".to_string(), + operational_failure = true; + warnings.push(Diagnostic { + code: "partial_apply".to_string(), path: Some(rel_str.clone()), - message: format!("write failed: {e}"), + message: format!("cannot read sidecar for edit: {e}"), }); - false + continue; } + }; + file_ops.push(FileWriteOp { + change_idx, + warn_path: rel_str.clone(), + write: PlannedWrite { + path: sidecar_rel, + kind: WriteKind::Sidecar, + before, + after: Some(body.into_bytes()), + affected_files: vec![state.path.clone()], + }, + }); + } + } + + // Preparation also covers the license-text inventory: missing bundled + // texts are planned LicenseText writes, missing custom/fetchable texts + // are explicit blockers. Validation failures here prevent all writes. + let (text_writes, text_blocked) = + match inventory::plan_text_writes(&root, &projected_reference_ids(&scan.states, mode)) { + Ok(plan) => plan, + Err(e) => { + operational_failure = true; + warnings.push(Diagnostic { + code: "missing_license_text".to_string(), + path: None, + message: format!("cannot plan license texts: {e}"), + }); + (Vec::new(), Vec::new()) } }; + // Group annotation requests by destination document for preview records + // and failure filing (the batch itself groups identically inside). + let mut toml_groups: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for (k, req) in toml_reqs.iter().enumerate() { + toml_groups + .entry(oob::annotation_destination(&oob, &req.rel).doc_rel) + .or_default() + .push(k); + } - changes.push(FileChange { - path: change_path, - mode: ChangeMode::Destructive, - target_header: None, - wrote_header: true, - preserved_copyrights: preserved, - applied, + // Every write record, in one place: dry-run previews them all as Planned, + // real execution replaces them with outcomes below. + let mut executed: Vec = Vec::new(); + // Applied-write count for apply_exit (includes durability-committed ones). + let mut changed: usize = 0; + // Whether the license-text inventory blocks the gate independent of states. + let texts_ok = text_blocked.is_empty(); + for b in &text_blocked { + executed.push(ExecutedWrite::blocked( + PlannedWrite { + path: PathBuf::from(format!("LICENSES/{}.txt", b.id)), + kind: WriteKind::LicenseText, + before: None, + after: None, + affected_files: Vec::new(), + }, + b.message.clone(), + )); + warnings.push(Diagnostic { + code: "missing_license_text".to_string(), + path: None, + message: b.message.clone(), }); } - // Materialize referenced-but-missing license texts (offline) unless dry-run. - if !args.dry_run - && let Ok(res) = inventory::materialize(&root, &scan.referenced_ids) - { - for id in res.still_missing { - warnings.push(Warning { - kind: "missing_license_text".to_string(), - path: None, - message: format!("no offline text for `{id}` (use lint --allow-network)"), - }); + if args.dry_run { + // Nothing is executed or fetched: the same records preview as + // Planned, and projected_pass predicts the gate. + for op in &file_ops { + executed.push(ExecutedWrite::planned(op.write.clone())); + } + for (doc_rel, members) in &toml_groups { + let before = match std::fs::read(root.join(doc_rel)) { + Ok(b) => Some(b), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => { + operational_failure = true; + warnings.push(Diagnostic { + code: "partial_apply".to_string(), + path: Some(doc_rel.to_string_lossy().replace('\\', "/")), + message: format!("cannot preview metadata document: {e}"), + }); + continue; + } + }; + executed.push(ExecutedWrite::planned(PlannedWrite { + path: doc_rel.clone(), + kind: WriteKind::ReuseToml, + before, + after: None, + affected_files: members + .iter() + .map(|&k| PathBuf::from(&toml_reqs[k].rel)) + .collect(), + })); + } + for w in &text_writes { + executed.push(ExecutedWrite::planned(w.clone())); + } + } else { + // Execution: single-file writes via the library executor in + // deterministic destination order, then per-document metadata + // batches, then planned license texts. Reads all settled above. + let mut single: Vec = Vec::new(); + // (kind, path) -> (change index for file writes, diagnostic path). + let mut filing: std::collections::HashMap<(String, PathBuf), (Option, String)> = + std::collections::HashMap::new(); + for op in &file_ops { + filing.insert( + (op.write.kind.as_str().to_string(), op.write.path.clone()), + (Some(op.change_idx), op.warn_path.clone()), + ); + single.push(op.write.clone()); + } + for w in &text_writes { + filing.insert( + (w.kind.as_str().to_string(), w.path.clone()), + (None, w.path.to_string_lossy().replace('\\', "/")), + ); + single.push(w.clone()); + } + for outcome in crate::exec::execute_writes(&root, &single) { + let key = ( + outcome.write.kind.as_str().to_string(), + outcome.write.path.clone(), + ); + let (change_idx, warn_path) = + filing.get(&key).cloned().unwrap_or((None, String::new())); + match outcome.status { + WriteStatus::Applied => { + changed += 1; + if let Some(ci) = change_idx { + changes[ci].applied = true; + } + } + WriteStatus::Failed => { + if outcome.replacement_completed { + // Committed but unfsynced: a change with a diagnostic. + changed += 1; + if let Some(ci) = change_idx { + changes[ci].applied = true; + } + } else { + operational_failure = true; + } + warnings.push(Diagnostic { + code: "partial_apply".to_string(), + path: Some(outcome.write.path.to_string_lossy().replace('\\', "/")), + message: format!( + "write failed for {}: {}", + if warn_path.is_empty() { + outcome.write.path.to_string_lossy().replace('\\', "/") + } else { + warn_path + }, + outcome.message.as_deref().unwrap_or("unknown error") + ), + }); + } + WriteStatus::Blocked | WriteStatus::Planned | WriteStatus::Unchanged => {} + } + executed.push(outcome); + } + + if !toml_reqs.is_empty() { + let requests: Vec> = toml_reqs + .iter() + .map(|req| oob::AnnotationRequest { + rel_path: &req.rel, + license: &req.license, + copyrights: &req.copyrights, + }) + .collect(); + match oob::write_annotations(&root, &requests, &oob) { + Ok(outcome) => { + for (j, req) in toml_reqs.iter().enumerate() { + if outcome.per_request[j].modified() { + changed += 1; + changes[req.change_idx].applied = true; + } + } + for patch in &outcome.patches { + executed.push(ExecutedWrite { + write: PlannedWrite { + path: patch.doc_rel.clone(), + kind: WriteKind::ReuseToml, + before: patch.before.clone(), + after: Some(patch.after.clone()), + affected_files: patch + .requests + .iter() + .map(|&k| PathBuf::from(&toml_reqs[k].rel)) + .collect(), + }, + status: WriteStatus::Applied, + message: None, + replacement_completed: true, + }); + } + } + // One shared batch: a failure fails every member request, and + // each destination document files one Failed record. + Err(e) => { + operational_failure = true; + for req in &toml_reqs { + warnings.push(Diagnostic { + code: "partial_apply".to_string(), + path: Some(req.warn_path.clone()), + message: format!("write failed: {e}"), + }); + } + for (doc_rel, members) in &toml_groups { + executed.push(ExecutedWrite { + write: PlannedWrite { + path: doc_rel.clone(), + kind: WriteKind::ReuseToml, + before: None, + after: None, + affected_files: members + .iter() + .map(|&k| PathBuf::from(&toml_reqs[k].rel)) + .collect(), + }, + status: WriteStatus::Failed, + message: Some(e.to_string()), + replacement_completed: false, + }); + } + } + } } } - // Re-scan post-write so the report and exit code reflect the actual on-disk result - // (dry-run keeps the pre-apply states, since nothing was written). - let partial = any_failure && any_success; - let (final_states, scan_warnings) = if args.dry_run { - (scan.states, scan.warnings.clone()) + // Re-scan post-write so the report and exit code reflect the actual on-disk + // result. Verification always reads the working tree over the frozen path + // set — never a recomputed subset, and never the stale index after + // `apply --staged` edited the worktree. A verification failure retains + // the attempted write results instead of propagating: exit 3 when any + // write succeeded, since the outcome is unverified but mutated. + let mut complete = true; + let (final_states, scan_warnings, violations) = if args.dry_run { + // Dry-run predicts: planning clean, nothing blocked by design, and + // the text inventory complete. (before_pass is reported separately; + // a failing gate with a complete plan still projects success.) + let projected_pass = !operational_failure && !blocked_by_design && texts_ok; + (scan.states, scan.warnings.clone(), !projected_pass) } else { - let mut fresh = super::check::open_cache(&args.common, &root, &config_text); - let r = engine.scan(&selection, &mut fresh)?; - (r.states, r.warnings) + let verify_engine = Engine::new( + root.clone(), + &config, + Snapshot::Worktree { root: root.clone() }, + true, + ); + match verify_engine.scan(&frozen) { + Ok(r) => { + let bad = !crate::report::counts_pass(&crate::report::count_drift( + &r.states, + &r.warnings, + )) || !texts_ok; + (r.states, r.warnings, bad) + } + Err(e) => { + operational_failure = true; + complete = false; + warnings.push(Diagnostic { + code: "partial_apply".to_string(), + path: None, + message: format!("post-write verification scan failed: {e}"), + }); + // Unverifiable: fail closed, but keep every attempted write. + (scan.states, scan.warnings.clone(), true) + } + } }; - // Final report warnings: post-write detection warnings, then apply-pass warnings. + // Final report diagnostics: post-write detection diagnostics, then + // apply-pass diagnostics. let mut all_warnings = scan_warnings; all_warnings.append(&mut warnings); let warnings = all_warnings; - let exit = if partial { - ExitCode::Partial - } else if has_unfixable(&final_states) || any_failure { - ExitCode::Violations - } else { - ExitCode::Success - }; + // One observation triple drives both the summary and the process exit: + // writes that all succeed but leave drift are violations (exit 1), not + // partial — partial means the tool itself failed partway. + let exit = crate::error::apply_exit(changed, operational_failure, violations); + let partial = matches!(exit, ExitCode::Partial); - let mut report = Report::build("apply", &final_states, &changes, warnings, Some(partial)); + let mut report = Report::build( + "apply", + &final_states, + &changes, + warnings, + Some(partial), + crate::report::ReportMeta { + snapshot: snapshot_label, + writes: executed, + projected_pass: args.dry_run.then_some(!violations), + before_pass: Some(before_pass), + complete, + extra_violations: !texts_ok, + }, + ); report.exit_code = Some(exit.code()); - match args.common.format { - Format::Json => println!("{}", report.to_json()), - Format::Human => print!("{}", render_human(&report)), - } + let rendered = match args.common.format { + Format::Json => format!("{}\n", report.to_json()), + Format::Human => render_human(&report), + }; + super::emit_stdout(&rendered)?; Ok(exit) } +/// License identifiers the post-apply selected state will need texts for: +/// intents of files apply touches (plus their current licenses in additive +/// mode, which are preserved), and current effective licenses — including +/// snippets — of files it leaves alone. Excluded files are out of scope; +/// stale replaced licenses and unmatched rules never enter. +fn projected_reference_ids( + states: &[crate::domain::FileLicensingState], + mode: ChangeMode, +) -> std::collections::BTreeSet { + use crate::engine::collect_ids; + let mut out = std::collections::BTreeSet::new(); + for s in states { + if matches!(s.drift, DriftClass::Excluded) { + continue; + } + let will_touch = matches!( + s.drift, + DriftClass::MissingHeader + | DriftClass::WrongLicense { .. } + | DriftClass::CopyrightMismatch { .. } + | DriftClass::Unreadable + ) && s.declared_intent.is_some(); + if will_touch { + collect_ids( + &s.declared_intent + .as_ref() + .expect("intent checked") + .license_expression, + &mut out, + ); + if mode == ChangeMode::Additive { + for c in crate::detect::candidate_licenses(&s.actual) { + collect_ids(&c, &mut out); + } + } + } else { + for c in crate::detect::candidate_licenses(&s.actual) { + collect_ids(&c, &mut out); + } + } + for snippet in &s.actual.snippet_licenses { + collect_ids(snippet, &mut out); + } + } + out +} + /// True when Uncovered/Unreadable files remain (apply cannot fix by writing — exit 1). -fn has_unfixable(states: &[crate::domain::FileLicensingState]) -> bool { - states - .iter() - .any(|s| matches!(s.drift, DriftClass::Uncovered | DriftClass::Unreadable)) +/// Working-tree state for the dirty-tree guard. A Git launch/status failure is an +/// error (never "clean"), and a non-repository has no undo guarantee. +enum TreeState { + Clean, + Dirty, + NoGitGuarantee, } -/// Detect an uncommitted working tree via `git status --porcelain`. -fn is_dirty(root: &Path) -> bool { - match std::process::Command::new("git") - .arg("-C") - .arg(root) - .args(["status", "--porcelain"]) - .output() - { - Ok(out) if out.status.success() => !out.stdout.is_empty(), - // Not a git repo (or git missing) → treat as clean so apply still works. - _ => false, +fn tree_state(root: &Path) -> Result { + use crate::walk::git::{RepoDisposition, discover_repo, git_output}; + use std::ffi::OsStr; + match discover_repo(root)? { + RepoDisposition::NonRepo => Ok(TreeState::NoGitGuarantee), + RepoDisposition::Bare => Err(LicetError::Config( + "bare git repository has no working tree to modify".to_string(), + )), + RepoDisposition::Repo(repo) => { + let out = git_output( + &repo.root, + &[OsStr::new("status"), OsStr::new("--porcelain")], + )?; + Ok(if out.is_empty() { + TreeState::Clean + } else { + TreeState::Dirty + }) + } } } - -/// Exposed for selection used by the dry-run preview surface. -#[allow(dead_code)] -fn _selection_doc(_: &Selection) {} diff --git a/src/cli/check.rs b/src/cli/check.rs index d7f7daa..7ff517d 100644 --- a/src/cli/check.rs +++ b/src/cli/check.rs @@ -1,51 +1,92 @@ //! `check` — non-writing gate (FR-012, FR-012a, FR-013; US1, US4). -use std::path::Path; - use super::{CheckArgs, Format}; use crate::config::LicensingConfiguration; -use crate::engine::{Engine, default_cache_path}; +use crate::engine::Engine; use crate::error::{ExitCode, LicetError, Result}; +use crate::report::Diagnostic; use crate::report::Report; -use crate::report::render::{render_explain, render_human}; -use crate::walk::cache::{ScanCache, config_fingerprint}; -use crate::walk::{Selection, discover_root}; +use crate::report::render::{ExplainInput, render_explain, render_human}; +use crate::reuse::inventory::LicenseTextInventory; +use crate::rules::{Match, RuleSet}; +use crate::walk::{self, Purpose, Selection, discover_root}; pub fn run(args: CheckArgs) -> Result { let cwd = std::env::current_dir()?; - let root = discover_root(&cwd); - let config_text = read_config(&args.common.config)?; - let config = LicensingConfiguration::from_toml(&config_text)?; + let (root, _) = discover_root(&cwd)?; + + // --explain resolves one path directly: no whole-tree content scan and no + // cache writes. A path outside the selected set (or not on disk) is a + // usage diagnostic, never success. + if let Some(target) = &args.explain { + return explain_one(&args, &cwd, target); + } + let selection = args.common.selection()?; + let config_arg = args.common.config_arg(&cwd, &root); - let mut cache = open_cache(&args.common, &root, &config_text); - let engine = Engine::new(root.clone(), &config, &config_text); - let scan = engine.scan(&selection, &mut cache)?; - cache.flush().ok(); - - // --explain: print the winning rule for a single path and exit 0. - if let Some(path) = &args.explain { - let rel = path.strip_prefix(&root).unwrap_or(path); - if let Some(state) = scan - .states - .iter() - .find(|s| s.path == *rel || s.path == *path) - { - println!( - "{}", - render_explain( - &state.path.to_string_lossy(), - &state.drift, - &state.matched_rule - ) - ); - } else { - println!("{}: not found in the selected file set", path.display()); - } - return Ok(ExitCode::Success); + // Resolve paths, snapshot, and configuration in one consistent step: + // `--staged` reads the index (including staged metadata/config/texts). + let prep = walk::prepare(&cwd, &config_arg, &selection, Purpose::Policy, false)?; + let config = LicensingConfiguration::from_toml(&prep.config_text)?; + + warn_deprecated_cache_flags(&args.common); + let engine = Engine::new(root.clone(), &config, prep.snapshot.clone(), true); + let scan = engine.scan(&prep.paths)?; + + let mut warnings = scan.warnings; + if let Some(note) = prep.expansion_note { + warnings.push(Diagnostic { + code: "selection_expanded".to_string(), + path: None, + message: note, + }); } - let mut report = Report::build("check", &scan.states, &[], scan.warnings, None); + // `check` requires the license texts referenced by its selected files — + // actual effective plus declared desired scope (decision 1). Unused and + // unrecognized project entries never fail a selected-file policy check. + let mut scope = scan.actual_referenced_ids.clone(); + scope.extend(scan.desired_referenced_ids.iter().cloned()); + let inv = LicenseTextInventory::compute(&prep.snapshot, &scope)?; + if !inv.missing.is_empty() { + let missing: Vec = inv.missing.iter().cloned().collect(); + warnings.push(Diagnostic { + code: "missing_license_text".to_string(), + path: None, + message: format!( + "license texts referenced by the selected files are missing under LICENSES/: {}", + missing.join(", ") + ), + }); + } + let texts = crate::report::LicenseTexts { + referenced: inv.referenced.iter().cloned().collect(), + present: inv.present.iter().cloned().collect(), + missing: inv.missing.iter().cloned().collect(), + bundled_available: inv.bundled_available.iter().cloned().collect(), + spdx_list_version: crate::spdx::spdx_list_version().to_string(), + unused: Vec::new(), + unrecognized: Vec::new(), + missing_extension: Vec::new(), + }; + + let mut report = Report::build( + "check", + &scan.states, + &[], + warnings, + None, + crate::report::ReportMeta { + snapshot: prep.snapshot.source().as_str().to_string(), + complete: true, + ..Default::default() + }, + ); + if !inv.missing.is_empty() { + report.summary.pass = false; + } + report.license_texts = Some(texts); let exit = if report.summary.pass { ExitCode::Success } else { @@ -53,31 +94,192 @@ pub fn run(args: CheckArgs) -> Result { }; report.exit_code = Some(exit.code()); - match args.common.format { - Format::Json => println!("{}", report.to_json()), - Format::Human => print!("{}", render_human(&report)), - } + let rendered = match args.common.format { + Format::Json => format!("{}\n", report.to_json()), + Format::Human => render_human(&report), + }; + super::emit_stdout(&rendered)?; Ok(exit) } -fn read_config(path: &Path) -> Result { - std::fs::read_to_string(path) - .map_err(|e| LicetError::Config(format!("cannot read config `{}`: {e}", path.display()))) -} - -/// Open the scan cache honoring `--no-cache` / `--cache ` (SC-006). -pub fn open_cache(common: &super::CommonArgs, root: &Path, config_text: &str) -> ScanCache { - if common.no_cache { - return ScanCache::disabled(); +/// `--cache` / `--no-cache` are retained for one compatibility window as +/// documented deprecated no-ops: scans are stateless and never create files. +/// Warns at most with a stderr notice. +pub fn warn_deprecated_cache_flags(common: &super::CommonArgs) { + if common.no_cache || common.cache.is_some() { + eprintln!( + "warning: --cache/--no-cache are deprecated no-ops; licet scans are stateless and never cache" + ); } - let path = common - .cache - .clone() - .unwrap_or_else(|| default_cache_path(root)); - ScanCache::open(&path, &config_fingerprint(config_text)) } /// Helper reused by apply for selection resolution context. pub fn resolve_selection(common: &super::CommonArgs) -> Result { common.selection() } + +/// Explain one path: winning rule index/selector (or default), losing +/// matches with specificity, exclusions, metadata provenance, and current +/// drift — evaluated from working-tree bytes for this path alone, with a +/// disabled cache that writes nothing (FR-002, FR-022). +fn explain_one( + args: &CheckArgs, + cwd: &std::path::Path, + target: &std::path::Path, +) -> Result { + let (root, _) = discover_root(cwd)?; + // Normalize through the explicit-file pipeline: outside-root and + // nonregular inputs fail here as usage errors. + let single = walk::prepare( + cwd, + &args.common.config_arg(cwd, &root), + &Selection::Files(vec![target.to_path_buf()]), + Purpose::Policy, + false, + )?; + let discovered = single.paths.first().cloned().ok_or_else(|| { + LicetError::Internal(format!( + "--explain: no evaluable path for {}", + target.display() + )) + })?; + let rel = discovered.rel_path.clone(); + match std::fs::symlink_metadata(single.root.join(&rel)) { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(LicetError::Config(format!( + "--explain: no such file {}", + target.display() + ))); + } + Err(e) => { + return Err(LicetError::Config(format!( + "--explain: cannot stat {}: {e}", + target.display() + ))); + } + } + // An explicit selection restricts the answerable set. + if !matches!(args.common.selection()?, Selection::FullTree) { + let selected = walk::prepare( + cwd, + &args.common.config_arg(cwd, &root), + &args.common.selection()?, + Purpose::Policy, + false, + )?; + if !selected.paths.iter().any(|d| d.rel_path == rel) { + return Err(LicetError::Config(format!( + "--explain: {} is outside the selected file set", + target.display() + ))); + } + } + + let config = LicensingConfiguration::from_toml(&single.config_text)?; + let engine = Engine::new(single.root.clone(), &config, single.snapshot.clone(), true); + let scan = engine.scan(std::slice::from_ref(&discovered))?; + let state = scan.states.first().ok_or_else(|| { + LicetError::Internal(format!("--explain: no state for {}", target.display())) + })?; + + let ruleset = RuleSet::new(&config); + let (winner, default_intent, in_conflict) = match ruleset.resolve(&rel) { + Match::Rule(r) => ( + Some(( + r.source_order + 1, + r.label(), + r.intent.license_expression.clone(), + )), + None, + false, + ), + Match::Default(d) => (None, d.map(|i| i.license_expression.clone()), false), + Match::Conflict(_) => (None, None, true), + }; + // `matching_rules` sorts most specific first with earliest declaration + // breaking ties — the same order `resolve` picks from — so element zero + // is the winner whenever there is no conflict. + let all_matches = crate::rules::matching_rules(&config, &rel); + let mut conflict_rules: Vec<(usize, String)> = Vec::new(); + let mut losers: Vec<(usize, String, u32)> = Vec::new(); + if in_conflict { + let top = all_matches.first().map(|(_, spec)| *spec).unwrap_or(0); + for (rule, spec) in &all_matches { + let entry = (rule.source_order + 1, rule.label()); + if *spec == top { + conflict_rules.push(entry); + } else { + losers.push((entry.0, entry.1, *spec)); + } + } + } else { + for (rule, spec) in all_matches.iter().skip(1) { + losers.push((rule.source_order + 1, rule.label(), *spec)); + } + } + + let excludes = walk::build_excludes(&config.exclude)?; + let slash = rel.to_string_lossy().replace('\\', "/"); + let mut sources = Vec::new(); + if let Some(s) = &state.actual.detected_source { + sources.push(format!("detected via {}", s.as_str())); + } + if let Some(oob) = &state.actual.out_of_band { + for o in &oob.origins { + sources.push(format!( + "{} table {} ({})", + o.metadata_path.to_string_lossy().replace('\\', "/"), + o.table_index, + o.precedence.as_str() + )); + } + } + if !state.actual.snippet_licenses.is_empty() { + sources.push(format!( + "{} snippet license(s) (inventoried, never file policy)", + state.actual.snippet_licenses.len() + )); + } + if !state.actual.invalid_license_values.is_empty() { + sources.push(format!( + "{} rejected license value(s), kept for diagnosis", + state.actual.invalid_license_values.len() + )); + } + + let rel_display = slash.clone(); + match args.common.format { + Format::Json => { + let mut report = Report::build( + "check", + &scan.states, + &[], + scan.warnings, + None, + crate::report::ReportMeta { + snapshot: single.snapshot.source().as_str().to_string(), + complete: true, + ..Default::default() + }, + ); + report.exit_code = Some(ExitCode::Success.code()); + super::emit_stdout(&format!("{}\n", report.to_json()))?; + } + Format::Human => { + super::emit_stdout(&render_explain(&ExplainInput { + path: &rel_display, + drift: &state.drift, + winner, + default_intent, + conflict_rules, + losers, + excluded_by_config: excludes.is_match(&slash), + reuse_ignored: discovered.reuse_ignored, + sources, + snapshot: single.snapshot.source().as_str(), + }))?; + } + } + Ok(ExitCode::Success) +} diff --git a/src/cli/init.rs b/src/cli/init.rs index 141e7be..a40826b 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -1,102 +1,435 @@ -//! `init` / `bootstrap` — derive a config from current repository state (FR-018; US5). +//! `init` — derive a config from current repository state (FR-018; US5). //! //! Inspects existing headers and `REUSE.toml`/`.reuse/dep5`, then emits a `license.toml` -//! whose projection reproduces current licensing — generalizing per-file observations into -//! extension/glob rules where possible (SC-008). Modifies no source files. +//! whose projection reproduces current licensing. Preservation outranks brevity: +//! every observed file first becomes an exact-path rule, and rules compress to an +//! extension group only when every observed path they would match carries the same +//! license. A `[default]` is emitted only when every covered file is known, with exact +//! exceptions for the rest; previously unknown files remain unknown. The generated +//! config is validated against every observation with the real rule resolver before +//! anything is written. Modifies no source files. use std::collections::BTreeMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use super::{Format, InitArgs}; use crate::config::LicensingConfiguration; use crate::detect; -use crate::error::{ExitCode, Result}; +use crate::error::{ExitCode, LicetError, Result}; +use crate::report::{Counts, Diagnostic, Report, Summary, WriteEntry}; use crate::reuse::oob::OutOfBand; -use crate::walk::{self, Selection, discover_root}; +use crate::rules::{Match, RuleSet}; +use crate::walk::{self, Purpose, Selection, discover_root}; + +/// One observed coverable file: repo-relative path plus its effective license +/// (`None` = unknown — unlicensed, unreadable, or unrepresentable here). +struct Observation { + path: PathBuf, + license: Option, +} pub fn run(args: InitArgs) -> Result { let cwd = std::env::current_dir()?; - let root = discover_root(&cwd); - let oob = OutOfBand::load(&root); + let (root, _) = discover_root(&cwd)?; + + // Destination: `--output`, else explicit `--config`, else `/license.toml`. + // Explicit relative paths resolve from the invocation cwd. + let explicit = args.output.clone().or(args.config.clone()); + let abs_out = match &explicit { + Some(p) if p.is_absolute() => p.clone(), + Some(p) => cwd.join(p), + None => root.join("license.toml"), + }; + + // Init observes working-tree state; declaration excludes do not apply to + // observation (there is no config yet to declare them). + let prep = walk::prepare( + &cwd, + Path::new("license.toml"), + &Selection::FullTree, + Purpose::Lint, + true, + )?; + let oob = OutOfBand::load_snapshot(&prep.snapshot)?; - // Observe each file's detected license, keyed by extension. - let discovered = walk::enumerate(&root, &Selection::FullTree, &[])?; - let mut by_ext: BTreeMap> = BTreeMap::new(); - let mut license_counts: BTreeMap = BTreeMap::new(); + // The artifact being written is not an observation: with `--force` it + // exists, but its bytes are the old policy, not licensable content. + let skip_rel = abs_out + .strip_prefix(&prep.root) + .ok() + .map(|p| p.to_path_buf()); - for d in &discovered { - if d.excluded { + let mut observations: Vec = Vec::new(); + for d in &prep.paths { + if d.reuse_ignored { continue; } - let head = detect::read_head(&d.abs_path).unwrap_or_default(); - let sidecar = detect::read_sidecar(&d.abs_path); - let actual = detect::detect(&d.rel_path, &head, sidecar.as_deref(), &oob); - let lic = match actual.detected_license { - Some(l) => l, - None => continue, - }; - *license_counts.entry(lic.clone()).or_default() += 1; - if let Some(ext) = d.rel_path.extension().and_then(|e| e.to_str()) { - *by_ext - .entry(ext.to_string()) - .or_default() - .entry(lic) - .or_default() += 1; + if let Some(skip) = &skip_rel + && *skip == d.rel_path + { + continue; } + let full = match prep.snapshot.read(&d.rel_path) { + Ok(Some(bytes)) => bytes, + // Unreadable files cannot be observed; they stay explicitly unknown. + _ => { + observations.push(Observation { + path: d.rel_path.clone(), + license: None, + }); + continue; + } + }; + let sidecar_rel = crate::walk::git::sidecar_for(&d.rel_path); + let sidecar = prep.snapshot.read(&sidecar_rel).ok().flatten(); + let actual = detect::detect(&d.rel_path, &full, sidecar.as_deref(), &oob); + observations.push(Observation { + path: d.rel_path.clone(), + license: actual.detected_license, + }); + } + observations.sort_by(|a, b| a.path.cmp(&b.path)); + + let generated = generate(&observations)?; + let toml = render_config(&generated); + + // Validate the generated config with the real loader and resolver before + // writing: every known license must project back exactly, and every + // unknown file must remain uncovered. + let parsed = LicensingConfiguration::from_toml(&toml) + .map_err(|e| LicetError::Internal(format!("init generated an invalid config: {e}")))?; + verify_projection(&parsed, &observations)?; + + // Create-new is the default: an existing destination (file or symlink) is + // refused unless `--force` replaces exactly the bytes just observed. + let expected = crate::reuse::read_expected_for_write(&abs_out) + .map_err(|e| LicetError::Config(format!("cannot read `{}`: {e}", abs_out.display())))?; + if expected.is_some() && !args.force { + return Err(LicetError::Config(format!( + "refusing to overwrite existing `{}` without --force", + abs_out.display() + ))); } - // Most common license overall becomes the default. - let default_license = license_counts + let display = display_path(&prep.root, &abs_out); + let (status, message, exit) = match write_config(&abs_out, expected.as_deref(), &toml) { + Ok(()) => ("applied".to_string(), None, ExitCode::Success), + Err(e) => ( + "failed".to_string(), + Some(e.to_string()), + ExitCode::Violations, + ), + }; + + let known_paths: Vec = observations .iter() - .max_by_key(|(_, n)| **n) - .map(|(l, _)| l.clone()); - - // For each extension with a single dominant license differing from the default, - // emit an `ext` rule (prefers ext/glob over per-file rules — SC-008). - let mut rules: Vec<(String, String)> = Vec::new(); - for (ext, licenses) in &by_ext { - if let Some((lic, _)) = licenses.iter().max_by_key(|(_, n)| **n) - && Some(lic) != default_license.as_ref() - { - rules.push((ext.clone(), lic.clone())); + .filter(|o| o.license.is_some()) + .map(|o| slash(&o.path)) + .collect(); + let unknown_paths: Vec = observations + .iter() + .filter(|o| o.license.is_none()) + .map(|o| slash(&o.path)) + .collect(); + let diagnostics: Vec = unknown_paths + .iter() + .map(|p| Diagnostic { + code: "missing_license".to_string(), + path: Some(p.clone()), + message: "no licensing metadata observed; left uncovered by the generated config" + .to_string(), + }) + .collect(); + let report = Report { + version: 2, + command: "init".to_string(), + exit_code: Some(exit.code()), + snapshot: "worktree".to_string(), + summary: Summary { + pass: exit == ExitCode::Success, + partial: None, + complete: true, + before_pass: None, + projected_pass: None, + counts: Counts::default(), + }, + files: Vec::new(), + diagnostics, + writes: vec![WriteEntry { + path: display.clone(), + kind: "config".to_string(), + status, + affected_files: known_paths, + before_text: None, + after_text: Some(toml.clone()), + message, + }], + license_texts: None, + }; + + match args.format { + Format::Json => super::emit_stdout(&format!("{}\n", report.to_json()))?, + Format::Human => { + if exit == ExitCode::Success { + eprintln!( + "Wrote {} ({} rule(s) inferred, {} known, {} unknown).", + display, + generated.rules.len(), + observations.len() - unknown_paths.len(), + unknown_paths.len(), + ); + for u in &unknown_paths { + eprintln!("unknown: {u}"); + } + } else { + eprintln!("init failed: {}", message_of(&report)); + // Never print a config that was not written: stdout stays + // empty so callers cannot mistake it for the new policy. + return Ok(exit); + } + super::emit_stdout(&toml)?; } } + Ok(exit) +} - let toml = render_config(default_license.as_deref(), &rules); +fn message_of(report: &Report) -> String { + report + .writes + .first() + .and_then(|w| w.message.clone()) + .unwrap_or_default() +} - // Validate the generated config parses (projection sanity). - LicensingConfiguration::from_toml(&toml)?; +/// A generated `[[rule]]`: exactly one selector key plus the observed license. +/// Copyright is always `preserve` (never a guessed holder), which is the +/// loader default, so it is not serialized. +struct GeneratedRule { + ext: Option, + file: Option, + license: String, +} - let out_path = args - .output - .clone() - .unwrap_or_else(|| PathBuf::from("license.toml")); - std::fs::write(&out_path, &toml)?; +struct GeneratedConfig { + default: Option, + rules: Vec, +} - match args.format { - Format::Json => println!( - "{{\"generated\":\"{}\",\"rules\":{},\"from_reuse\":{}}}", - out_path.display(), - rules.len(), - args.from_reuse - ), - Format::Human => { - eprintln!( - "Wrote {} ({} rule(s) inferred).", - out_path.display(), - rules.len() - ); - print!("{toml}"); +/// Exact-path rules first; compress to extension rules only for provably +/// uniform groups; default only when every covered file is known. +fn generate(observations: &[Observation]) -> Result { + // Extension groups: lowercase ext -> (emitted spelling, licenses, paths). + let mut ext_groups: BTreeMap)> = BTreeMap::new(); + let mut ext_of_unknown: std::collections::BTreeSet = Default::default(); + for o in observations { + let Some(ext) = o.path.extension().and_then(|e| e.to_str()) else { + continue; + }; + let key = ext.to_ascii_lowercase(); + match &o.license { + Some(lic) => { + ext_groups + .entry(key) + .or_insert_with(|| (ext.to_string(), Vec::new())) + .1 + .push(lic.clone()); + } + None => { + ext_of_unknown.insert(key); + } + } + } + + // An ext group compresses only when every observed path it would match + // carries the same license — i.e. no unknown file shares the extension + // and all known ones agree. + let mut rules: Vec = Vec::new(); + let mut ext_rule_for: std::collections::BTreeSet = Default::default(); + for (key, (spelling, licenses)) in &ext_groups { + if ext_of_unknown.contains(key) { + continue; + } + let mut iter = licenses.iter(); + let first = iter.next().expect("group is nonempty"); + if iter.all(|l| crate::spdx::expressions_equal(l, first)) { + rules.push(GeneratedRule { + ext: Some(spelling.clone()), + file: None, + license: first.clone(), + }); + ext_rule_for.insert(key.clone()); + } + } + // …exact rules for everything they do not cover. + for o in observations { + let Some(lic) = &o.license else { continue }; + let covered_by_ext = o + .path + .extension() + .and_then(|e| e.to_str()) + .map(|e| ext_rule_for.contains(&e.to_ascii_lowercase())) + .unwrap_or(false); + if covered_by_ext { + continue; } + rules.push(GeneratedRule { + ext: None, + file: Some(exact_value(&o.path)), + license: lic.clone(), + }); + } + + // Default only when every covered file is known: the most common license, + // keeping exact/ext exceptions for the rest. + let unknowns = observations.iter().filter(|o| o.license.is_none()).count(); + let mut default: Option = None; + if unknowns == 0 && !observations.is_empty() { + let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); + for o in observations { + *counts + .entry(o.license.as_deref().unwrap_or("")) + .or_default() += 1; + } + let top = counts + .iter() + .max_by_key(|(_, n)| **n) + .map(|(l, _)| (*l).to_string()); + if let Some(top_lic) = top { + rules.retain(|r| !crate::spdx::expressions_equal(&r.license, &top_lic)); + // A default with no exceptions that covers a single file each is + // still a default; but a default equal to nothing observed is + // pointless — `top` always names an observed license, so keep it. + default = Some(top_lic); + } + } + // Deterministic order: ext rules by extension, then exact rules by path. + rules.sort_by(|a, b| { + let key = |r: &GeneratedRule| { + ( + r.ext.is_none(), + r.ext.clone().unwrap_or_default(), + r.file.clone().unwrap_or_default(), + ) + }; + key(a).cmp(&key(b)) + }); + Ok(GeneratedConfig { default, rules }) +} + +/// The `file` selector value for an observed path: paths containing `/` are +/// exact already; a root-level file is emitted as `./name` so the loader pins +/// it as an [`ExactPath`](crate::domain::Selector::ExactPath) instead of a +/// directory-spanning filename match. +fn exact_value(rel: &Path) -> String { + let s = slash(rel); + if s.contains('/') { s } else { format!("./{s}") } +} + +fn slash(p: &Path) -> String { + p.to_string_lossy().replace('\\', "/") +} + +/// Re-resolve every observation through the real rule set: known licenses +/// must come back equal, unknown files must stay uncovered. +fn verify_projection(config: &LicensingConfiguration, observations: &[Observation]) -> Result<()> { + let set = RuleSet::new(config); + for o in observations { + match (&o.license, set.resolve(&o.path)) { + (Some(expected), Match::Rule(r)) => { + if !crate::spdx::expressions_equal(&r.intent.license_expression, expected) { + return Err(LicetError::Internal(format!( + "init projection mismatch for `{}`: observed `{expected}` but generated config resolves `{}`", + slash(&o.path), + r.intent.license_expression + ))); + } + } + (Some(expected), Match::Default(Some(intent))) => { + if !crate::spdx::expressions_equal(&intent.license_expression, expected) { + return Err(LicetError::Internal(format!( + "init projection mismatch for `{}`: observed `{expected}` but generated default resolves `{}`", + slash(&o.path), + intent.license_expression + ))); + } + } + (None, Match::Default(None)) => {} + (Some(_), Match::Default(None)) => { + return Err(LicetError::Internal(format!( + "init left known file `{}` uncovered", + slash(&o.path) + ))); + } + (Some(_), Match::Conflict(c)) => { + return Err(LicetError::Internal(format!( + "init generated conflicting rules for `{}`: {}", + slash(&o.path), + c.message + ))); + } + (None, Match::Rule(r)) => { + return Err(LicetError::Internal(format!( + "init covers previously unknown file `{}` via `{}`", + slash(&o.path), + r.label() + ))); + } + (None, Match::Default(Some(intent))) => { + return Err(LicetError::Internal(format!( + "init default covers previously unknown file `{}` (`{}`)", + slash(&o.path), + intent.license_expression + ))); + } + (None, Match::Conflict(c)) => { + return Err(LicetError::Internal(format!( + "init generated conflicting rules for unknown file `{}`: {}", + slash(&o.path), + c.message + ))); + } + } + } + Ok(()) +} + +/// Write through the shared safe writer: the destination's parent is the +/// allowed root, so an explicit `--output` elsewhere authorizes only that +/// particular config destination. +fn write_config( + abs_out: &Path, + expected: Option<&[u8]>, + toml: &str, +) -> std::result::Result<(), crate::reuse::WriteError> { + let bad = |msg: String| crate::reuse::WriteError { + source: std::io::Error::new(std::io::ErrorKind::InvalidInput, msg), + replacement_completed: false, + }; + let parent = abs_out.parent().ok_or_else(|| { + bad(format!( + "cannot determine parent of output {}", + abs_out.display() + )) + })?; + let file_name = abs_out + .file_name() + .ok_or_else(|| bad(format!("output {} names no file", abs_out.display())))?; + crate::reuse::atomic_write(parent, Path::new(file_name), expected, toml.as_bytes()) +} + +fn display_path(root: &Path, abs: &Path) -> String { + match abs.strip_prefix(root) { + Ok(rel) => slash(rel), + Err(_) => abs.to_string_lossy().into_owned(), } - Ok(ExitCode::Success) } /// Serializable shape of the generated `license.toml`. Emitted via the `toml` crate so any -/// license id or extension is correctly quoted/escaped — hand-rolled `"{d}"` interpolation -/// previously produced invalid TOML for values containing quotes, backslashes, or newlines. +/// license id, extension, or path is correctly quoted/escaped — hand-rolled `"{d}"` +/// interpolation previously produced invalid TOML for values containing quotes, +/// backslashes, or newlines. #[derive(serde::Serialize)] -struct GeneratedConfig { +struct RenderedConfig { #[serde(skip_serializing_if = "Option::is_none")] default: Option, #[serde(rename = "rule", skip_serializing_if = "Vec::is_empty")] @@ -110,20 +443,26 @@ struct DefaultSection { #[derive(serde::Serialize)] struct RuleSection { - ext: String, + #[serde(skip_serializing_if = "Option::is_none")] + ext: Option, + #[serde(skip_serializing_if = "Option::is_none")] + file: Option, license: String, } -fn render_config(default: Option<&str>, rules: &[(String, String)]) -> String { - let config = GeneratedConfig { - default: default.map(|d| DefaultSection { - license: d.to_string(), - }), - rules: rules +fn render_config(generated: &GeneratedConfig) -> String { + let config = RenderedConfig { + default: generated + .default + .as_ref() + .map(|d| DefaultSection { license: d.clone() }), + rules: generated + .rules .iter() - .map(|(ext, lic)| RuleSection { - ext: ext.clone(), - license: lic.clone(), + .map(|r| RuleSection { + ext: r.ext.clone(), + file: r.file.clone(), + license: r.license.clone(), }) .collect(), }; @@ -143,17 +482,28 @@ mod tests { // interpolated raw — the original `format!("license = \"{d}\"")` emitted invalid // TOML so `init` crashed with a *TOML parse error* validating its own output. // Escaping reduces that to, at worst, a clear semantic SPDX error from the loader. - let toml = render_config( - Some("MIT\nx=1\n\")"), - &[("r\"s".to_string(), "Apache-2.0 OR \"GPL\"".to_string())], - ); + let toml = render_config(&GeneratedConfig { + default: Some("MIT\nx=1\n\")".to_string()), + rules: vec![GeneratedRule { + ext: Some("r\"s".to_string()), + file: None, + license: "Apache-2.0 OR \"GPL\"".to_string(), + }], + }); toml::from_str::(&toml) .expect("generated output must be syntactically valid TOML"); } #[test] fn render_is_well_formed_for_ordinary_input() { - let toml = render_config(Some("MIT"), &[("rs".to_string(), "Apache-2.0".to_string())]); + let toml = render_config(&GeneratedConfig { + default: Some("MIT".to_string()), + rules: vec![GeneratedRule { + ext: Some("rs".to_string()), + file: None, + license: "Apache-2.0".to_string(), + }], + }); assert!( toml.contains("[default]") && toml.contains("license = \"MIT\""), "{toml}" @@ -164,4 +514,91 @@ mod tests { ); LicensingConfiguration::from_toml(&toml).expect("ordinary config must parse"); } + + #[test] + fn root_file_exact_value_is_dot_prefixed() { + assert_eq!(exact_value(Path::new("Makefile")), "./Makefile"); + assert_eq!(exact_value(Path::new("sub/f.rs")), "sub/f.rs"); + } + + #[test] + fn heterogeneous_ext_group_keeps_exact_rules_and_no_default() { + let obs = vec![ + Observation { + path: PathBuf::from("a.rs"), + license: Some("MIT".to_string()), + }, + Observation { + path: PathBuf::from("b.rs"), + license: Some("Apache-2.0".to_string()), + }, + Observation { + path: PathBuf::from("notes.txt"), + license: None, + }, + ]; + let gen_cfg = generate(&obs).unwrap(); + assert!(gen_cfg.default.is_none(), "unknowns present → no default"); + assert!( + gen_cfg.rules.iter().all(|r| r.ext.is_none()), + "heterogeneous ext group must not compress: {:?}", + gen_cfg.rules.iter().map(|r| &r.license).collect::>() + ); + assert_eq!(gen_cfg.rules.len(), 2); + } + + #[test] + fn uniform_ext_group_compresses_and_unknown_ext_blocks() { + let obs = vec![ + Observation { + path: PathBuf::from("a.py"), + license: Some("MIT".to_string()), + }, + Observation { + path: PathBuf::from("b.py"), + license: Some("MIT".to_string()), + }, + Observation { + path: PathBuf::from("c.js"), + license: None, + }, + Observation { + path: PathBuf::from("d.js"), + license: Some("MIT".to_string()), + }, + ]; + let gen_cfg = generate(&obs).unwrap(); + assert!(gen_cfg.default.is_none()); + assert!( + gen_cfg + .rules + .iter() + .any(|r| r.ext.as_deref() == Some("py") && r.license == "MIT"), + "uniform py group compresses" + ); + assert!( + gen_cfg + .rules + .iter() + .any(|r| r.file.as_deref() == Some("./d.js")), + "js group has an unknown sibling → d.js stays exact" + ); + } + + #[test] + fn all_known_single_license_becomes_default() { + let obs = vec![ + Observation { + path: PathBuf::from("a.py"), + license: Some("MIT".to_string()), + }, + Observation { + path: PathBuf::from("b.py"), + license: Some("MIT".to_string()), + }, + ]; + let gen_cfg = generate(&obs).unwrap(); + assert_eq!(gen_cfg.default.as_deref(), Some("MIT")); + assert!(gen_cfg.rules.is_empty()); + } } diff --git a/src/cli/lint.rs b/src/cli/lint.rs index 1a8b2ac..ed44b37 100644 --- a/src/cli/lint.rs +++ b/src/cli/lint.rs @@ -1,29 +1,168 @@ //! `lint` — REUSE-compatibility & license-text report (FR-014, FR-017, FR-028; US5). +//! +//! `lint` validates **actual** REUSE 3.3 metadata over every covered file, +//! independently of whether any declaration rule exists: each covered file +//! needs a license expression and a copyright notice, and every referenced +//! license text must exist under `LICENSES/`. It never parses auto-discovered +//! policy configuration — a `license.toml` in the tree is simply another +//! covered file whose own licensing metadata is checked. + +use std::collections::{HashMap, HashSet}; +use std::path::Path; use super::{Format, LintArgs}; use crate::config::LicensingConfiguration; +use crate::domain::DriftClass; use crate::engine::Engine; -use crate::error::{ExitCode, Result}; +use crate::error::{ExitCode, LicetError, Result}; +use crate::report::Diagnostic; +use crate::report::classify::evaluate_reuse; use crate::reuse::inventory::LicenseTextInventory; use crate::spdx; -use crate::walk::cache::ScanCache; -use crate::walk::{Selection, discover_root}; +use crate::walk::{Purpose, Selection, discover_root, prepare}; pub fn run(args: LintArgs) -> Result { let cwd = std::env::current_dir()?; - let root = discover_root(&cwd); - let config_text = std::fs::read_to_string(&args.config).unwrap_or_default(); - let config = LicensingConfiguration::from_toml(&config_text).unwrap_or_default(); + let (root, _) = discover_root(&cwd)?; + + // An explicitly supplied config must exist and parse (usage error 2 + // otherwise); it is accepted with a deprecation notice but never changes + // REUSE evaluation. + if let Some(cfg_path) = &args.config { + let abs = if cfg_path.is_absolute() { + cfg_path.clone() + } else { + cwd.join(cfg_path) + }; + let text = std::fs::read_to_string(&abs).map_err(|e| { + LicetError::Config(format!( + "cannot read explicit lint config `{}`: {e}", + abs.display() + )) + })?; + LicensingConfiguration::from_toml(&text)?; + eprintln!( + "warning: `lint --config` is deprecated and ignored: lint validates actual REUSE metadata, not declared policy" + ); + } - let mut cache = ScanCache::disabled(); - let engine = Engine::new(root.clone(), &config, &config_text); - let scan = engine.scan(&Selection::FullTree, &mut cache)?; + // REUSE validation covers tracked plus nonignored untracked files and never + // applies declaration `[exclude]` rules (a config exclusion cannot hide a + // file from a whole-project compliance claim). The discovered policy text + // is deliberately never parsed: it is just another covered file. + let prep = prepare( + &cwd, + Path::new("license.toml"), + &Selection::FullTree, + Purpose::Lint, + true, + )?; + let config = LicensingConfiguration::default(); - let inv = LicenseTextInventory::compute(&root, &scan.referenced_ids); + let engine = Engine::new(root.clone(), &config, prep.snapshot.clone(), false); + let scan = engine.scan(&prep.paths)?; - // REUSE posture: every covered file has a license, and every referenced text is present. - let header_failures = scan.states.iter().filter(|s| s.drift.is_failure()).count(); - let compliant = header_failures == 0 && inv.is_complete(); + // Snapshot read failures by path, for incomplete-validation diagnostics. + let read_errors: HashMap = scan + .warnings + .iter() + .filter(|w| w.code == "read_error") + .filter_map(|w| { + w.path + .as_deref() + .map(|p| (p.to_string(), w.message.as_str())) + }) + .collect(); + + // Policy-only (`rule_conflict`) and superseded (`invalid_license`, restated + // per file by REUSE evaluation below with the stable code) diagnostics + // never reach lint output. + let mut warnings: Vec = scan + .warnings + .iter() + .filter(|w| w.code != "rule_conflict" && w.code != "invalid_license") + .cloned() + .collect(); + let mut failing_files: HashSet = HashSet::new(); + let mut incomplete = false; + for state in &scan.states { + let rel = state.path.to_string_lossy().replace('\\', "/"); + let excluded = matches!(state.drift, DriftClass::Excluded); + let eval = evaluate_reuse( + excluded, + &state.actual, + read_errors.get(rel.as_str()).copied(), + ); + if eval.incomplete { + incomplete = true; + } + if !eval.passed { + failing_files.insert(rel.clone()); + } + for d in eval.diagnostics { + warnings.push(Diagnostic { + code: d.code.to_string(), + path: Some(rel.clone()), + message: d.message, + }); + } + } + warnings.sort_by_key(|w| (w.path.clone(), w.code.clone())); + + // License-text inventory over actual references only: declaration rules + // never contribute to REUSE actuals, and unused config rules stay out. + let inv = LicenseTextInventory::compute(&prep.snapshot, &scan.actual_referenced_ids)?; + for id in &inv.missing { + warnings.push(Diagnostic { + code: "missing_license_text".to_string(), + path: None, + message: format!("license text for `{id}` is missing under LICENSES/"), + }); + } + for id in &inv.unused { + warnings.push(Diagnostic { + code: "unused_license_text".to_string(), + path: None, + message: format!("LICENSES/{id} is never referenced"), + }); + } + for u in &inv.unrecognized { + warnings.push(Diagnostic { + code: "bad_license_text".to_string(), + path: Some(u.path.clone()), + message: format!("unrecognized license text: {}", u.reason), + }); + } + for id in &inv.missing_extension { + warnings.push(Diagnostic { + code: "missing_license_extension".to_string(), + path: Some(format!("LICENSES/{id}")), + message: format!( + "`{id}` is kept in an extensionless file; REUSE 3.3 requires a filename extension" + ), + }); + } + for u in &inv.unreadable { + incomplete = true; + warnings.push(Diagnostic { + code: "unsupported_encoding".to_string(), + path: Some(u.path.clone()), + message: format!("cannot validate license text: {}", u.reason), + }); + } + + let compliant = failing_files.is_empty() && inv.is_complete() && !incomplete; + + // The evaluated covered set (sorted repo-relative paths, excluding + // REUSE-ignored files): the differential suite compares this against the + // reference tool's file list. + let mut coverage: Vec = scan + .states + .iter() + .filter(|s| !matches!(s.drift, DriftClass::Excluded)) + .map(|s| s.path.to_string_lossy().replace('\\', "/")) + .collect(); + coverage.sort(); match args.format { Format::Json => { @@ -33,47 +172,82 @@ pub fn run(args: LintArgs) -> Result { missing: inv.missing.iter().cloned().collect(), bundled_available: inv.bundled_available.iter().cloned().collect(), spdx_list_version: spdx::spdx_list_version().to_string(), + unused: inv.unused.clone(), + unrecognized: inv.unrecognized.iter().map(|u| u.path.clone()).collect(), + missing_extension: inv.missing_extension.clone(), }; let report = serde_json::json!({ - "version": 1, + "version": 2, "command": "lint", "exit_code": if compliant { 0 } else { 1 }, - "summary": { "pass": compliant, "counts": {} }, + "summary": { "pass": compliant, "complete": true, "counts": {} }, "files": [], + "coverage": coverage, + "diagnostics": warnings, "license_texts": texts, }); - println!("{}", serde_json::to_string_pretty(&report).unwrap()); + // Serializing a `serde_json::Value` cannot fail; the fallback keeps + // a serialization bug from panicking instead of reporting. + let body = serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string()); + super::emit_stdout(&format!("{body}\n"))?; } Format::Human => { - println!( - "REUSE compliance posture (SPDX list {}):", + // Built as one document so stdout goes through the shared + // emitter (quiet on a closed pipe instead of panicking). + let mut human = format!( + "REUSE compliance posture (SPDX list {}):\n", spdx::spdx_list_version() ); - println!(" files failing header coverage: {header_failures}"); - println!(" LICENSES/ present: {}", inv.present.len()); + human.push_str(&format!( + " files failing REUSE validation: {}\n", + failing_files.len() + )); + if incomplete { + human.push_str(" validation incomplete: some files or texts could not be read\n"); + } + human.push_str(&format!(" LICENSES/ present: {}\n", inv.present.len())); if !inv.missing.is_empty() { - println!(" missing license texts:"); + human.push_str(" missing license texts:\n"); for id in &inv.missing { let hint = if spdx::bundled_text(id).is_some() { "available offline (run `licet add-license`)" } else if spdx::is_license_ref(id) { - "custom LicenseRef — scaffold a placeholder" + "custom LicenseRef — supply the text manually" } else if args.allow_network { "absent from bundle — would fetch (network allowed)" } else { "absent from bundle — needs --allow-network" }; - println!(" - {id} ({hint})"); + human.push_str(&format!(" - {id} ({hint})\n")); + } + } + if !inv.unused.is_empty() { + human.push_str(" unused license texts:\n"); + for id in &inv.unused { + human.push_str(&format!(" - {id}\n")); + } + } + if !inv.unrecognized.is_empty() { + human.push_str(" unrecognized LICENSES/ entries:\n"); + for u in &inv.unrecognized { + human.push_str(&format!(" - {} ({})\n", u.path, u.reason)); } } - println!( - "Result: {}", + if !inv.missing_extension.is_empty() { + human.push_str(" license texts missing a filename extension:\n"); + for id in &inv.missing_extension { + human.push_str(&format!(" - LICENSES/{id}\n")); + } + } + human.push_str(&format!( + "Result: {}\n", if compliant { "COMPLIANT" } else { "NON-COMPLIANT" } - ); + )); + super::emit_stdout(&human)?; } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 2476a0e..21f1928 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,7 +6,7 @@ pub mod check; pub mod init; pub mod lint; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use clap::{Args, CommandFactory, Parser, Subcommand}; use clap_complete::Shell; @@ -73,9 +73,11 @@ impl From for crate::domain::NonAnnotatableStrategy { /// Flags shared by `check` and `apply` (selection, config, format, cache). #[derive(Debug, Args)] pub struct CommonArgs { - /// Path to the declarative config. - #[arg(long, default_value = "license.toml", global = true)] - pub config: PathBuf, + /// Path to the declarative config (default: `/license.toml` from + /// the discovered root, so subdirectories work; an explicit relative + /// path resolves from the invocation cwd). + #[arg(long, global = true)] + pub config: Option, /// Output rendering. #[arg(long, value_enum, default_value_t = Format::Human, global = true)] @@ -101,16 +103,30 @@ pub struct CommonArgs { #[arg(long, value_name = "REV", num_args = 0..=1, default_missing_value = "HEAD", global = true)] pub changed: Option, - /// Disable the scan cache. + /// Deprecated no-op (retained for compatibility): scans are stateless + /// and never cache. May print a stderr notice. #[arg(long, global = true)] pub no_cache: bool, - /// Relocate the scan cache. + /// Deprecated no-op (retained for compatibility): scans are stateless + /// and never cache. May print a stderr notice. #[arg(long, value_name = "PATH", global = true)] pub cache: Option, } impl CommonArgs { + /// Resolve the config argument against the discovered root: an explicit + /// path stays invocation-cwd-relative (absolute passes through); an + /// omitted config defaults to `/license.toml` so every command + /// works from a subdirectory (FR-001). + pub fn config_arg(&self, cwd: &Path, root: &Path) -> PathBuf { + match &self.config { + Some(p) if p.is_absolute() => p.clone(), + Some(p) => cwd.join(p), + None => root.join("license.toml"), + } + } + /// Resolve the file selection, enforcing mutual exclusivity (FR-027). pub fn selection(&self) -> Result { let mut chosen = 0; @@ -194,22 +210,35 @@ pub struct ApplyArgs { #[derive(Debug, Args)] pub struct InitArgs { - #[arg(long, default_value = "license.toml")] - pub config: PathBuf, - /// Derive from existing REUSE state (headers + REUSE.toml/.reuse/dep5). + /// Read as the destination when `--output` is absent (init writes a + /// config, so `--config` names where it goes, not where policy comes + /// from). Defaults to `/license.toml`. + #[arg(long)] + pub config: Option, + /// Compatibility alias: init always inspects existing REUSE state + /// (headers plus `REUSE.toml`/`.reuse/dep5`), so this changes nothing. #[arg(long)] pub from_reuse: bool, - /// Output path for the generated config. + /// Output path for the generated config (overrides `--config`). + /// Relative paths resolve from the invocation cwd. #[arg(long)] pub output: Option, + /// Replace the destination when it already exists. Without it, init + /// creates new and refuses to overwrite (existing files and symlinks + /// are never followed or truncated). + #[arg(long)] + pub force: bool, #[arg(long, value_enum, default_value_t = Format::Human)] pub format: Format, } #[derive(Debug, Args)] pub struct LintArgs { - #[arg(long, default_value = "license.toml")] - pub config: PathBuf, + /// Explicit policy config path (compatibility only): it must exist and + /// parse, is accepted with a deprecation notice, and never changes REUSE + /// evaluation. Omitted by default — lint needs no configuration. + #[arg(long)] + pub config: Option, #[arg(long, value_enum, default_value_t = Format::Human)] pub format: Format, /// Permit fetching license ids absent from the offline bundle. @@ -230,8 +259,10 @@ pub struct AddLicenseArgs { #[arg(long)] pub allow_network: bool, /// Path to the declarative config (only read by --all to discover referenced ids). - #[arg(long, default_value = "license.toml")] - pub config: PathBuf, + /// Defaults to `/license.toml`; an explicit relative path resolves + /// from the invocation cwd. + #[arg(long)] + pub config: Option, #[arg(long, value_enum, default_value_t = Format::Human)] pub format: Format, } @@ -244,10 +275,31 @@ pub struct CompletionsArgs { } /// Print a completion script for `shell` to stdout. -pub fn print_completions(shell: Shell) { +pub fn print_completions(shell: Shell) -> Result<()> { let mut cmd = Cli::command(); let name = cmd.get_name().to_string(); - clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout()); + let mut buf = Vec::new(); + clap_complete::generate(shell, &mut cmd, name, &mut buf); + emit_stdout(&String::from_utf8_lossy(&buf)) +} + +/// Write a complete output document to stdout. +/// +/// A closed pipe (the reader went away first, e.g. `| head`) terminates +/// quietly with exit 0 instead of panicking on `EPIPE`; any other output +/// error is returned normally for the caller to report. +pub fn emit_stdout(text: &str) -> Result<()> { + use std::io::Write; + let mut out = std::io::stdout().lock(); + let mut quiet_pipe = |e: std::io::Error| { + if e.kind() == std::io::ErrorKind::BrokenPipe { + std::process::exit(0); + } + e + }; + out.write_all(text.as_bytes()).map_err(&mut quiet_pipe)?; + out.flush().map_err(quiet_pipe)?; + Ok(()) } /// Render the `--version` line including the embedded SPDX list version (FR-028). @@ -262,7 +314,7 @@ pub fn version_string() -> String { /// Dispatch a parsed CLI to its command, returning the process exit code. pub fn dispatch(cli: Cli) -> Result { if cli.version { - println!("{}", version_string()); + emit_stdout(&format!("{}\n", version_string()))?; return Ok(ExitCode::Success); } match cli.command { @@ -272,7 +324,7 @@ pub fn dispatch(cli: Cli) -> Result { Some(Command::Lint(args)) => lint::run(args), Some(Command::AddLicense(args)) => add_license::run(args), Some(Command::Completions(args)) => { - print_completions(args.shell); + print_completions(args.shell)?; Ok(ExitCode::Success) } None => Err(LicetError::Config( diff --git a/src/comment/mod.rs b/src/comment/mod.rs index fffc176..cbd6de1 100644 --- a/src/comment/mod.rs +++ b/src/comment/mod.rs @@ -1,7 +1,8 @@ //! Comment-style registry: built-in table seeded to the REUSE-known set, overlaid by //! user-defined associations from config (FR-010, FR-011). //! -//! Resolution precedence: exact filename → extension → built-in default. +//! Resolution precedence: config exact path → config filename → config +//! extension → built-in default. mod comment_style; mod extensions; @@ -42,7 +43,25 @@ pub fn render_header(syntax: &CommentSyntax, license: &str, copyrights: &[String /// (FR-015). Sidecars cover non-annotatable files; the REUSE spec treats their content as /// if it were inside the file. Lines are `\n`-terminated. pub fn render_sidecar(license: &str, copyrights: &[String]) -> String { - let lines = spdx_lines(license, copyrights); + render_sidecar_multi(std::slice::from_ref(&license.to_string()), copyrights) +} + +/// Render a `.license` sidecar body covering several licenses (additive, FR-006): +/// one `SPDX-License-Identifier` line per license — order-stable, deduplicated — +/// then the copyright lines. Lines are `\n`-terminated. +pub fn render_sidecar_multi(licenses: &[String], copyrights: &[String]) -> String { + // Copyright lines first, then one license line per license (order-stable, + // deduplicated) — the same layout as [`render_sidecar`]. + let mut lines: Vec = Vec::new(); + for c in copyrights { + lines.push(format!("SPDX-FileCopyrightText: {c}")); + } + for license in licenses { + let line = format!("SPDX-License-Identifier: {license}"); + if !lines.iter().any(|l| l == &line) { + lines.push(line); + } + } format!("{}\n", lines.join("\n")) } @@ -69,20 +88,33 @@ impl<'a> CommentResolver<'a> { } } - /// Resolve the comment syntax for `path` (FR-011 precedence: - /// exact filename association → extension association → built-in filename → - /// built-in extension). + /// Resolve the comment syntax for `path` (FR-011 precedence: config + /// exact-path association → config filename association → config extension + /// association → built-in filename → built-in extension). Exact paths use + /// the same slash-normalized semantics as rule selectors, so `a/foo` never + /// governs `b/foo`. pub fn resolve(&self, path: &Path) -> Option { + let norm = path.to_string_lossy().replace('\\', "/"); let filename = path.file_name().and_then(|n| n.to_str()); let ext = path.extension().and_then(|e| e.to_str()); - // 1. Config association by exact filename. + // 1. Config association by full normalized exact path. + if let Some(style) = self.associations.iter().find_map(|a| match &a.selector { + Selector::ExactPath(p) if *p == norm => self.materialize(&a.style), + _ => None, + }) { + return Some(style); + } + // 2. Config association by filename. if let Some(fname) = filename - && let Some(style) = self.assoc_for_filename(fname) + && let Some(style) = self.associations.iter().find_map(|a| match &a.selector { + Selector::Filename(f) if f == fname => self.materialize(&a.style), + _ => None, + }) { return Some(style); } - // 2. Config association by extension. + // 3. Config association by extension. if let Some(e) = ext && let Some(style) = self.assoc_for_ext(e) { @@ -103,16 +135,6 @@ impl<'a> CommentResolver<'a> { None } - fn assoc_for_filename(&self, filename: &str) -> Option { - self.associations.iter().find_map(|a| match &a.selector { - Selector::Filename(f) if f == filename => self.materialize(&a.style), - Selector::ExactPath(p) if p.rsplit('/').next() == Some(filename) => { - self.materialize(&a.style) - } - _ => None, - }) - } - fn assoc_for_ext(&self, ext: &str) -> Option { self.associations.iter().find_map(|a| match &a.selector { Selector::Extension(e) if e.eq_ignore_ascii_case(ext) => self.materialize(&a.style), @@ -187,6 +209,47 @@ mod tests { assert!(r.resolve(&PathBuf::from("mystery.zzz")).is_none()); } + #[test] + fn exact_path_assoc_is_directory_scoped() { + // `a/foo` must never govern `b/foo`: exact paths match the full + // normalized path, not the filename. + let cfg = cfg_with(vec![ + CommentStyleAssociation { + selector: Selector::ExactPath("a/foo".into()), + style: CommentStyleRef::Inline(CommentSyntax::line_only("//")), + }, + CommentStyleAssociation { + selector: Selector::ExactPath("b/foo".into()), + style: CommentStyleRef::Inline(CommentSyntax::line_only("#")), + }, + ]); + let r = CommentResolver::new(&cfg); + let a = r.resolve(&PathBuf::from("a/foo")).unwrap(); + assert_eq!(line_prefix_of(&a), "//"); + let b = r.resolve(&PathBuf::from("b/foo")).unwrap(); + assert_eq!(line_prefix_of(&b), "#"); + } + + #[test] + fn exact_path_assoc_beats_filename_assoc() { + let cfg = cfg_with(vec![ + CommentStyleAssociation { + selector: Selector::Filename("foo".into()), + style: CommentStyleRef::Inline(CommentSyntax::line_only("#")), + }, + CommentStyleAssociation { + selector: Selector::ExactPath("a/foo".into()), + style: CommentStyleRef::Inline(CommentSyntax::line_only("//")), + }, + ]); + let r = CommentResolver::new(&cfg); + let s = r.resolve(&PathBuf::from("a/foo")).unwrap(); + assert_eq!(line_prefix_of(&s), "//"); + // The filename association still governs elsewhere. + let s = r.resolve(&PathBuf::from("b/foo")).unwrap(); + assert_eq!(line_prefix_of(&s), "#"); + } + /// Invariant: detection must parse a superset of what rendering emits, so every /// built-in style round-trips through `detect` — a header `licet` writes is always /// recognized again, never re-flagged as missing/wrong (render ⊆ parse). diff --git a/src/config/mod.rs b/src/config/mod.rs index a78bb37..5dad889 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -88,7 +88,17 @@ impl LicensingConfiguration { for cs in &raw.comment_styles { let selector = single_style_selector(cs)?; let style = match &cs.style { - RawStyle::Named(name) => CommentStyleRef::Named(name.clone()), + RawStyle::Named(name) => { + // Unknown aliases fail here (exit 2), never as a silent + // fallback to a different style at apply time. + if crate::comment::by_alias(name).is_none() { + return Err(LicetError::Config(format!( + "unknown comment style `{name}` for `{}`", + selector.label() + ))); + } + CommentStyleRef::Named(name.clone()) + } RawStyle::Inline(inline) => { let syntax = inline_syntax(inline, &selector)?; CommentStyleRef::Inline(syntax) @@ -117,22 +127,18 @@ impl LicensingConfiguration { Ok(config) } - /// Duplicate identical selectors with differing intent are config-level conflicts - /// (config-schema.md validation rule 5, FR-022). + /// Duplicate identical selectors with differing full intent (license or + /// copyright policy) are config-level conflicts (config-schema.md + /// validation rule 5, FR-022). fn check_duplicate_selectors(&self) -> Result<()> { for (i, a) in self.rules.iter().enumerate() { for b in &self.rules[i + 1..] { - if a.selector == b.selector - && !spdx::expressions_equal( - &a.intent.license_expression, - &b.intent.license_expression, - ) - { + if a.selector == b.selector && !crate::domain::intents_equal(&a.intent, &b.intent) { return Err(LicetError::Config(format!( - "duplicate selector `{}` with conflicting licenses `{}` vs `{}`", + "duplicate selector `{}` with conflicting intent (`{}` vs `{}`)", a.selector.label(), - a.intent.license_expression, - b.intent.license_expression + describe_intent(&a.intent), + describe_intent(&b.intent) ))); } } @@ -195,12 +201,7 @@ fn single_rule_selector(raw: &RawRule, order: usize) -> Result { found.push(Selector::Glob(g.clone())); } if let Some(f) = &raw.file { - // A `file` selector with path separators is an exact path; otherwise a filename. - if f.contains('/') { - found.push(Selector::ExactPath(f.clone())); - } else { - found.push(Selector::Filename(f.clone())); - } + found.push(file_selector(f)); } match found.len() { 1 => Ok(found.pop().unwrap()), @@ -215,16 +216,26 @@ fn single_rule_selector(raw: &RawRule, order: usize) -> Result { } } +/// Lower a `file` selector value: a value with path separators is an exact +/// path; a bare name matches that filename in any directory — except a +/// leading `./`, which pins a root-level file as an exact path (`init` +/// emits `./name` so a root file can never govern deeper namesakes). +fn file_selector(f: &str) -> Selector { + let mut v = f; + while let Some(rest) = v.strip_prefix("./") { + v = rest; + } + if v.contains('/') || v.len() != f.len() { + Selector::ExactPath(v.to_string()) + } else { + Selector::Filename(f.to_string()) + } +} + fn single_style_selector(cs: &schema::RawCommentStyle) -> Result { match (&cs.ext, &cs.file) { (Some(e), None) => Ok(Selector::Extension(e.trim_start_matches('.').to_string())), - (None, Some(f)) => { - if f.contains('/') { - Ok(Selector::ExactPath(f.clone())) - } else { - Ok(Selector::Filename(f.clone())) - } - } + (None, Some(f)) => Ok(file_selector(f)), (None, None) => Err(LicetError::Config( "comment_style entry has no selector (need exactly one of ext/file)".to_string(), )), @@ -234,9 +245,42 @@ fn single_style_selector(cs: &schema::RawCommentStyle) -> Result { } } +/// A comment token must not carry bytes that would break rendering or inject +/// new lines/tags into generated headers: CR, LF, NUL, or any other control +/// character. The interior `block_line_prefix` alone may be blank (existing +/// block styles use `""` or `" * "`), but never control-bearing. +fn validate_comment_token(token: &str, what: &str, selector: &Selector) -> Result<()> { + if token.trim().is_empty() && what != "block_line_prefix" { + return Err(LicetError::Config(format!( + "comment_style for `{}` has a blank {what}", + selector.label() + ))); + } + if let Some(bad) = token.chars().find(|c| c.is_control()) { + return Err(LicetError::Config(format!( + "comment_style for `{}` has a control character (U+{:04X}) in {what}", + selector.label(), + bad as u32 + ))); + } + Ok(()) +} + /// Lower a raw inline `[[comment_style]]` definition into a [`CommentSyntax`], /// rejecting empty or half-specified block definitions (data-model §4). fn inline_syntax(inline: &schema::RawInlineStyle, selector: &Selector) -> Result { + if let Some(p) = inline.line_prefix.as_deref() { + validate_comment_token(p, "line_prefix", selector)?; + } + if let Some(o) = inline.block_start.as_deref() { + validate_comment_token(o, "block_start", selector)?; + } + if let Some(c) = inline.block_end.as_deref() { + validate_comment_token(c, "block_end", selector)?; + } + if let Some(lp) = inline.block_line_prefix.as_deref() { + validate_comment_token(lp, "block_line_prefix", selector)?; + } let line = inline.line_prefix.as_deref().map(|p| LineStyle { prefix: SmolStr::new(p), }); @@ -294,8 +338,37 @@ fn validate_license(expr: &str, ctx: &str) -> Result<()> { spdx::validate_expression(expr).map_err(|e| LicetError::Config(format!("[{ctx}] {e}"))) } +/// One-line `license` + copyright-policy description for conflict messages. +fn describe_intent(intent: &LicenseIntent) -> String { + match &intent.copyright_policy { + CopyrightPolicy::Preserve => intent.license_expression.clone(), + CopyrightPolicy::PreserveAndAdd(t) => { + format!("{} + copyright add:{t}", intent.license_expression) + } + CopyrightPolicy::Replace(t) => { + format!("{} + copyright replace:{t}", intent.license_expression) + } + } +} + /// Parse the `copyright` policy string (`preserve` | `add:` | `replace:`). fn parse_copyright(value: Option<&str>, ctx: &str) -> Result { + /// Copyright text becomes a rendered header line: it must be nonempty and + /// a single line, so it cannot inject additional SPDX tags. + fn clean(text: &str, ctx: &str) -> Result { + let text = text.trim(); + if text.is_empty() { + return Err(LicetError::Config(format!( + "[{ctx}] copyright text must not be empty" + ))); + } + if text.contains(['\r', '\n']) { + return Err(LicetError::Config(format!( + "[{ctx}] copyright text must be a single line" + ))); + } + Ok(text.to_string()) + } match value { None => Ok(CopyrightPolicy::Preserve), Some(v) => { @@ -303,9 +376,9 @@ fn parse_copyright(value: Option<&str>, ctx: &str) -> Result { if v.eq_ignore_ascii_case("preserve") { Ok(CopyrightPolicy::Preserve) } else if let Some(text) = v.strip_prefix("add:") { - Ok(CopyrightPolicy::PreserveAndAdd(text.trim().to_string())) + Ok(CopyrightPolicy::PreserveAndAdd(clean(text, ctx)?)) } else if let Some(text) = v.strip_prefix("replace:") { - Ok(CopyrightPolicy::Replace(text.trim().to_string())) + Ok(CopyrightPolicy::Replace(clean(text, ctx)?)) } else { Err(LicetError::Config(format!( "[{ctx}] invalid copyright policy `{v}` (expected preserve | add: | replace:)" @@ -318,6 +391,35 @@ fn parse_copyright(value: Option<&str>, ctx: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use crate::domain::Selector; + + #[test] + fn dot_slash_file_value_is_exact_path() { + // `init` pins root-level files as `./name`; the loader must not read + // that as a directory-spanning filename match. + let cfg = LicensingConfiguration::from_toml( + "[[rule]]\nfile = \"./Makefile\"\nlicense = \"MIT\"\n\ + [[rule]]\nfile = \"Makefile\"\nlicense = \"Apache-2.0\"\n", + ) + .unwrap(); + assert_eq!( + cfg.rules[0].selector, + Selector::ExactPath("Makefile".to_string()) + ); + assert_eq!( + cfg.rules[1].selector, + Selector::Filename("Makefile".to_string()) + ); + let set = crate::rules::RuleSet::new(&cfg); + assert!(matches!( + set.resolve(Path::new("sub/Makefile")), + crate::rules::Match::Rule(r) if r.intent.license_expression == "Apache-2.0" + )); + assert!(matches!( + set.resolve(Path::new("Makefile")), + crate::rules::Match::Rule(r) if r.intent.license_expression == "MIT" + )); + } const MARQUE: &str = r#" [default] @@ -378,6 +480,25 @@ paths = ["vendor/**", "target/**"] assert!(format!("{err}").contains("duplicate selector")); } + #[test] + fn rejects_duplicate_selector_with_differing_copyright() { + // Full intent decides duplicates too: same license but different + // copyright policy still conflicts. + let err = LicensingConfiguration::from_toml( + "[[rule]]\next=\"rs\"\nlicense=\"MIT\"\n[[rule]]\next=\"rs\"\nlicense=\"MIT\"\ncopyright=\"add:2026 Acme\"\n", + ) + .unwrap_err(); + assert!(format!("{err}").contains("duplicate selector")); + } + + #[test] + fn accepts_duplicate_selector_with_identical_full_intent() { + LicensingConfiguration::from_toml( + "[[rule]]\next=\"rs\"\nlicense=\"MIT\"\ncopyright=\"add:2026 Acme\"\n[[rule]]\next=\"rs\"\nlicense=\"MIT\"\ncopyright=\"add:2026 Acme\"\n", + ) + .unwrap(); + } + #[test] fn copyright_policies_parse() { let cfg = LicensingConfiguration::from_toml( @@ -418,6 +539,62 @@ paths = ["vendor/**", "target/**"] ); } + #[test] + fn rejects_unknown_comment_style_alias() { + let err = LicensingConfiguration::from_toml( + "[[comment_style]]\next = \"x\"\nstyle = \"no-such-style\"\n", + ) + .unwrap_err(); + assert!( + format!("{err}").contains("unknown comment style"), + "got: {err}" + ); + } + + #[test] + fn rejects_blank_comment_tokens() { + for style in [ + "style = { line_prefix = \" \" }", + "style = { block_start = \"\", block_end = \"*/\" }", + "style = { block_start = \"/*\", block_end = \" \" }", + ] { + let toml = format!("[[comment_style]]\next = \"x\"\n{style}\n"); + let err = LicensingConfiguration::from_toml(&toml).unwrap_err(); + assert!(format!("{err}").contains("blank"), "got: {err}"); + } + // The interior block line prefix alone may be empty or whitespace. + LicensingConfiguration::from_toml( + "[[comment_style]]\next = \"x\"\nstyle = { block_start = \"/*\", block_end = \"*/\", block_line_prefix = \" * \" }\n", + ) + .unwrap(); + } + + #[test] + fn rejects_control_characters_in_comment_tokens() { + // `\\n` is a TOML escape: the parsed prefix carries a real newline. + let err = LicensingConfiguration::from_toml( + "[[comment_style]]\next = \"x\"\nstyle = { line_prefix = \"//\\n\" }\n", + ) + .unwrap_err(); + assert!(format!("{err}").contains("control character"), "got: {err}"); + } + + #[test] + fn rejects_empty_and_multiline_copyright_text() { + for copyright in ["copyright = \"add:\"", "copyright = \"replace: \""] { + let err = LicensingConfiguration::from_toml(&format!( + "[default]\nlicense = \"MIT\"\n{copyright}\n" + )) + .unwrap_err(); + assert!(format!("{err}").contains("must not be empty"), "got: {err}"); + } + let err = LicensingConfiguration::from_toml( + "[default]\nlicense = \"MIT\"\ncopyright = \"add:2026 A\\nSPDX-License-Identifier: MIT\"\n", + ) + .unwrap_err(); + assert!(format!("{err}").contains("single line"), "got: {err}"); + } + #[test] fn non_annotatable_defaults_to_sidecar() { let cfg = LicensingConfiguration::from_toml("[default]\nlicense=\"MIT\"\n").unwrap(); diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 5883a04..b8ac422 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -11,7 +11,9 @@ //! (`SPDX-SnippetBegin`/`SPDX-SnippetEnd`) are honored when parsing: ignored regions are //! dropped, and snippet licenses are collected separately from the file's own (FR-030). //! -//! For throughput (SC-006) only the file head is read and scanned (FR-037/§10). +//! Complete file bytes are scanned (no head cutoff — F12): a header anywhere counts, +//! invalid UTF-8 anywhere makes the file unreadable, and rejected license values are +//! kept with their line for diagnosis instead of being dropped silently. use std::path::{Path, PathBuf}; use std::sync::OnceLock; @@ -19,16 +21,12 @@ use std::sync::OnceLock; use aho_corasick::AhoCorasick; use crate::domain::{ - ActualLicenseState, ActualSource, HeaderBlock, OobSource, OutOfBandEntry, PositionAfter, - Precedence, + ActualLicenseState, ActualSource, HeaderBlock, InvalidLicenseValue, OobSource, OutOfBandEntry, + PositionAfter, Precedence, }; use crate::reuse::oob::OutOfBand; use crate::spdx; -/// Bytes of the file head scanned for headers. Headers live at the very top, so a few KB -/// is ample and keeps IO minimal. -const HEAD_BYTES: usize = 8 * 1024; - const LICENSE_TAG: &str = "SPDX-License-Identifier:"; const COPYRIGHT_TAG: &str = "SPDX-FileCopyrightText:"; @@ -69,60 +67,94 @@ pub fn sidecar_path(path: &Path) -> PathBuf { PathBuf::from(p) } -/// Read up to [`HEAD_BYTES`] from a file. -pub fn read_head(path: &Path) -> std::io::Result> { - use std::io::Read; - let mut f = std::fs::File::open(path)?; - let mut buf = vec![0u8; HEAD_BYTES]; - let n = f.read(&mut buf)?; - buf.truncate(n); - Ok(buf) -} - -/// Read the `.license` sidecar head, if a sidecar exists. -pub fn read_sidecar(abs_path: &Path) -> Option> { - read_head(&sidecar_path(abs_path)).ok() -} - -/// Detect the actual license state for `rel_path`, given the file head, an optional -/// `.license` sidecar, and out-of-band data. +/// Detect the actual license state for `rel_path`, given the complete file bytes, +/// an optional `.license` sidecar, and out-of-band data. +/// +/// The whole content is scanned (no head cutoff): a header anywhere counts, and +/// invalid UTF-8 anywhere (not just in the head) makes the file unreadable. +/// Rejected license values are kept with their line for diagnosis, never dropped. pub fn detect( rel_path: &Path, - head: &[u8], + full: &[u8], sidecar: Option<&[u8]>, oob: &OutOfBand, ) -> ActualLicenseState { - let out_of_band = oob.lookup(rel_path); + let mut out_of_band = oob.lookup(rel_path); // A `.license` sidecar supplies the file-level headers and renders the asset's own // bytes irrelevant (so a binary asset with a sidecar is fully readable). A malformed - // (non-UTF8) sidecar is ignored in favor of the asset head. + // (non-UTF8) sidecar falls back to the asset bytes; task 4 diagnoses sidecar + // encoding as an in-band error instead of this silent fallback. let (parsed, header_source, encoding_ok) = match sidecar { Some(sc) => match std::str::from_utf8(sc) { Ok(t) => (parse_headers(t), ActualSource::Sidecar, true), - Err(_) => parse_asset_head(head), + Err(_) => parse_asset_bytes(full), }, - None => parse_asset_head(head), + None => parse_asset_bytes(full), }; let ParsedFile { blocks: headers, snippet_licenses, + snippet_copyrights, + invalid_license_values, } = parsed; - // Gather copyrights from all sources (copyright is never erased — FR-009). - let mut copyrights: Vec = headers.iter().flat_map(|h| h.copyrights.clone()).collect(); + // An `override` barrier suppresses file/sidecar info entirely: the raw + // notices stay in `headers` (for preservation and surgical edits) but are + // excluded from every effective value (REUSE 3.3, reference behavior). + let suppressed = out_of_band.as_ref().is_some_and(|o| o.suppresses_file); + let file_licenses: Vec = if suppressed { + Vec::new() + } else { + headers.iter().flat_map(|h| h.license_ids.clone()).collect() + }; + let file_copyrights: Vec = if suppressed { + Vec::new() + } else { + headers.iter().flat_map(|h| h.copyrights.clone()).collect() + }; + + // Effective values: file-level info plus unconditional (`aggregate` and + // barrier) OOB contributions, plus the per-field `closest` fallback only + // where the file carries nothing for that field (reference `reuse_info_of`: + // a file with exactly one field still takes the other's fallback). + let mut eff_licenses = file_licenses.clone(); + let mut eff_copyrights = file_copyrights.clone(); if let Some(o) = &out_of_band { - copyrights.extend(o.copyrights.clone()); + push_unique(&mut eff_licenses, &o.licenses); + push_unique(&mut eff_copyrights, &o.copyrights); + if file_licenses.is_empty() { + push_unique(&mut eff_licenses, &o.fallback_licenses); + } + if file_copyrights.is_empty() { + push_unique(&mut eff_copyrights, &o.fallback_copyrights); + } } - - let header_licenses: Vec = headers.iter().flat_map(|h| h.license_ids.clone()).collect(); let (detected_license, detected_source) = - resolve_primary(&header_licenses, header_source, out_of_band.as_ref()); - - // A binary/non-UTF8 asset that is covered out-of-band (a REUSE.toml annotation with a - // license) is not "unreadable" — its licensing is known without reading its bytes - // (FR-025). Only a non-UTF8 asset with no coverage at all stays unreadable. - let covered_oob = out_of_band.as_ref().is_some_and(|o| o.license.is_some()); + resolve_primary(&file_licenses, header_source, out_of_band.as_ref()); + + // Retain only provenance that actually contributed: unconditional tables + // always, fallback tables only for a field the file left empty. + if let Some(o) = out_of_band.as_mut() { + let used_lic = file_licenses.is_empty() && !o.fallback_licenses.is_empty(); + let used_cpr = file_copyrights.is_empty() && !o.fallback_copyrights.is_empty(); + let (fb_lic, fb_cpr) = (o.fallback_licenses.clone(), o.fallback_copyrights.clone()); + o.origins.retain(|origin| { + if origin.precedence != Precedence::Closest { + return true; + } + (used_lic && !fb_lic.is_empty() && origin.licenses == fb_lic) + || (used_cpr && !fb_cpr.is_empty() && origin.copyrights == fb_cpr) + }); + } + + // A binary/non-UTF8 asset that is covered out-of-band (metadata with a + // license) is not "unreadable" — its licensing is known without reading + // its bytes (FR-025). Only a non-UTF8 asset with no coverage at all stays + // unreadable. + let covered_oob = out_of_band + .as_ref() + .is_some_and(|o| !o.licenses.is_empty() || !o.fallback_licenses.is_empty()); let encoding_ok = encoding_ok || covered_oob; ActualLicenseState { @@ -130,26 +162,31 @@ pub fn detect( out_of_band, detected_license, detected_source, - detected_copyrights: copyrights, + detected_copyrights: eff_copyrights, snippet_licenses, + snippet_copyrights, encoding_ok, + invalid_license_values, } } -/// Decode and parse the asset head into headers, reporting the source as `Header` and -/// whether the bytes were valid UTF-8. A clean head, or one whose only error is a -/// truncated trailing multibyte sequence (read-boundary cut), is treated as readable. -fn parse_asset_head(head: &[u8]) -> (ParsedFile, ActualSource, bool) { - let text = match std::str::from_utf8(head) { - Ok(t) => t.to_string(), - Err(e) if head.len() == HEAD_BYTES && e.error_len().is_none() => { - std::str::from_utf8(&head[..e.valid_up_to()]) - .unwrap_or("") - .to_string() +/// Append entries of `src` that `target` does not already hold. +fn push_unique(target: &mut Vec, src: &[String]) { + for item in src { + if !target.contains(item) { + target.push(item.clone()); } + } +} + +/// Decode and parse complete asset bytes into headers, reporting the source as +/// `Header` and whether the bytes were valid UTF-8 anywhere in the file. +fn parse_asset_bytes(full: &[u8]) -> (ParsedFile, ActualSource, bool) { + let text = match std::str::from_utf8(full) { + Ok(t) => t, Err(_) => return (ParsedFile::default(), ActualSource::Header, false), }; - (parse_headers(&text), ActualSource::Header, true) + (parse_headers(text), ActualSource::Header, true) } /// Resolve `OobSource` → `ActualSource`. @@ -160,81 +197,68 @@ fn oob_actual_source(o: &OutOfBandEntry) -> ActualSource { } } -/// Pick the primary detected license + its source, honoring the annotation's precedence -/// (FR-003a). `closest`/`aggregate` let file-level info win as the primary; `override` -/// lets the annotation win. Mirrors [`candidate_licenses`]. +/// Pick the primary detected license + its source. `header_licenses` is +/// already suppression-aware (empty under an `override` barrier), so the +/// first file-level license wins when present and OOB licenses — unconditional +/// first, then the `closest` fallback — only fill a gap. Mirrors +/// [`candidate_licenses`]. fn resolve_primary( header_licenses: &[String], header_source: ActualSource, oob: Option<&OutOfBandEntry>, ) -> (Option, Option) { - let header_first = header_licenses.first().cloned(); - let file_level = || match &header_first { - Some(h) => (Some(h.clone()), Some(header_source)), - None => (None, None), - }; + if let Some(first) = header_licenses.first() { + return (Some(first.clone()), Some(header_source)); + } match oob { - None => file_level(), - Some(o) => match o.precedence { - Precedence::Override => match o.license.clone() { - Some(l) => (Some(l), Some(oob_actual_source(o))), - None => file_level(), - }, - Precedence::Closest | Precedence::Aggregate => { - if header_first.is_some() { - file_level() - } else { - match o.license.clone() { - Some(l) => (Some(l), Some(oob_actual_source(o))), - None => (None, None), - } - } - } + None => (None, None), + Some(o) => match o.licenses.first().or(o.fallback_licenses.first()) { + Some(l) => (Some(l.clone()), Some(oob_actual_source(o))), + None => (None, None), }, } } -/// Every SPDX license expression that may satisfy intent for a file, honoring the -/// out-of-band annotation's precedence (FR-003a). Both `classify` and `reconcile` gate -/// compliance on this, so precedence flows everywhere from one place. +/// Every SPDX license expression that may satisfy intent for a file: effective +/// file-level licenses plus unconditional OOB contributions, plus the +/// `closest` fallback only when the file carries no license of its own +/// (FR-003a). Both `classify` and `reconcile` gate compliance on this, so +/// precedence flows everywhere from one place. pub fn candidate_licenses(state: &ActualLicenseState) -> Vec { - let header_licenses: Vec = state - .headers - .iter() - .flat_map(|h| h.license_ids.clone()) - .collect(); - let oob_lic = state.out_of_band.as_ref().and_then(|o| o.license.clone()); - match state.out_of_band.as_ref().map(|o| o.precedence) { - None => header_licenses, - Some(Precedence::Override) => match oob_lic { - Some(l) => vec![l], - None => header_licenses, - }, - Some(Precedence::Closest) => { - if header_licenses.is_empty() { - oob_lic.into_iter().collect() - } else { - header_licenses - } - } - Some(Precedence::Aggregate) => { - let mut v = header_licenses; - if let Some(l) = oob_lic - && !v.contains(&l) - { - v.push(l); - } - v + let suppressed = state + .out_of_band + .as_ref() + .is_some_and(|o| o.suppresses_file); + let mut v: Vec = if suppressed { + Vec::new() + } else { + state + .headers + .iter() + .flat_map(|h| h.license_ids.clone()) + .collect() + }; + // The fallback fills a file-level gap even when unconditional OOB values + // exist (the reference tool reports both in that case). + let file_empty = v.is_empty(); + if let Some(o) = state.out_of_band.as_ref() { + push_unique(&mut v, &o.licenses); + if file_empty { + push_unique(&mut v, &o.fallback_licenses); } } + v } /// In-file licensing parsed from text: file-level header blocks plus the licenses of any -/// SPDX snippets (collected for text inventory, never treated as the file's own license). +/// SPDX snippets (collected for text inventory, never treated as the file's own license), +/// plus every rejected license value with its line for diagnosis. #[derive(Default)] struct ParsedFile { blocks: Vec, snippet_licenses: Vec, + snippet_copyrights: Vec, + invalid_license_values: Vec, } /// Push and clear the in-progress file-level header block, if any. @@ -256,11 +280,14 @@ fn parse_headers(text: &str) -> ParsedFile { let position_after = leading_position(text); let mut blocks: Vec = Vec::new(); let mut snippet_licenses: Vec = Vec::new(); + let mut snippet_copyrights: Vec = Vec::new(); + let mut invalid_license_values: Vec = Vec::new(); let mut current: Option = None; let mut in_ignore = false; let mut in_snippet = false; - for (start, raw) in split_keep_offsets(text) { + for (line_no, (start, raw)) in split_keep_offsets(text).into_iter().enumerate() { + let line = line_no + 1; let end = start + raw.len(); // Single pass over the line: which control markers are present, and where each @@ -311,24 +338,65 @@ fn parse_headers(text: &str) -> ParsedFile { // anywhere on the line (comment-syntax-agnostic), so without this guard any prose or // code that merely follows the marker — e.g. the tag appearing inside a source // string literal — would be captured verbatim as the file's license (FR-005). - let lic = lic_at - .map(|i| trim_value(&raw[i..]).trim().to_string()) - .filter(|l| spdx::validate_expression(l).is_ok()); + // Rejected values are diagnosed with line + value, never dropped silently. + // The trimmed value stays a subslice of the line, so its exact byte span + // travels with it for span-based reconciliation (FR-007). + let raw_lic = lic_at.map(|i| { + let seg = &raw[i..]; + let val = trim_value(seg).trim(); + let off = val.as_ptr() as usize - seg.as_ptr() as usize; + ( + val.to_string(), + (start + i + off, start + i + off + val.len()), + ) + }); + let lic = match raw_lic { + Some((value, span)) => match spdx::validate_expression(&value) { + Ok(()) => Some((value, span)), + Err(reason) => { + invalid_license_values.push(InvalidLicenseValue { + line, + value, + reason, + }); + None + } + }, + None => None, + }; if in_snippet { - // Snippet licensing is gathered for inventory only — never file-level. - if let Some(l) = lic { + // Snippet notices are gathered for inventory and REUSE validation — + // never file-level policy (decision 5). The reference tool flattens + // snippet notices into the file's info, so lint counts them too. + if let Some((l, _)) = lic { snippet_licenses.push(l); } + if let Some(i) = cpr_at { + let val = trim_value(&raw[i..]).trim(); + if !val.is_empty() { + snippet_copyrights.push(val.to_string()); + } + } continue; } - let cpr = cpr_at.map(|i| trim_value(&raw[i..])); + let cpr = cpr_at.map(|i| { + let seg = &raw[i..]; + let val = trim_value(seg).trim(); + let off = val.as_ptr() as usize - seg.as_ptr() as usize; + ( + val.to_string(), + (start + i + off, start + i + off + val.len()), + ) + }); if lic.is_some() || cpr.is_some() { let block = current.get_or_insert_with(|| HeaderBlock { byte_range: (start, end), license_ids: Vec::new(), + license_spans: Vec::new(), copyrights: Vec::new(), + copyright_spans: Vec::new(), position_after: if blocks.is_empty() { position_after } else { @@ -336,11 +404,13 @@ fn parse_headers(text: &str) -> ParsedFile { }, }); block.byte_range.1 = end; - if let Some(l) = lic { + if let Some((l, span)) = lic { block.license_ids.push(l); + block.license_spans.push(span); } - if let Some(c) = cpr { - block.copyrights.push(c.trim().to_string()); + if let Some((c, span)) = cpr { + block.copyrights.push(c); + block.copyright_spans.push(span); } } else { flush_block(&mut current, &mut blocks); @@ -350,6 +420,8 @@ fn parse_headers(text: &str) -> ParsedFile { ParsedFile { blocks, snippet_licenses, + snippet_copyrights, + invalid_license_values, } } @@ -424,6 +496,24 @@ mod tests { use super::*; use std::path::PathBuf; + #[test] + fn license_spans_cover_exact_value_bytes() { + // A tag with a code prefix still validates when the value itself is + // clean; each value keeps its own exact span so reconciliation can + // locate it — and refuse it — without reparsing the line. (A tag + // inside a string literal carries trailing quote junk and is rejected + // by expression validation instead.) + let text = "/* SPDX-License-Identifier: MIT */\nFOO=SPDX-License-Identifier: Apache-2.0\n"; + let h = parse_headers(text).blocks; + // Adjacent tag lines merge. + assert_eq!(h.len(), 1); + assert_eq!(h[0].license_ids, vec!["MIT", "Apache-2.0"]); + assert_eq!(h[0].license_spans.len(), 2); + for (span, expect) in h[0].license_spans.iter().zip(["MIT", "Apache-2.0"]) { + assert_eq!(&text[span.0..span.1], expect); + } + } + #[test] fn parses_single_header() { let h = parse_headers( @@ -620,11 +710,22 @@ mod tests { } fn oob_with(license: &str, precedence: Precedence) -> OutOfBandEntry { + // A `closest` entry carries its value as the fallback (used only when + // the file has no license); other precedences contribute unconditionally, + // and `override` additionally suppresses file-level info. + let (licenses, fallback_licenses) = match precedence { + Precedence::Closest => (Vec::new(), vec![license.to_string()]), + _ => (vec![license.to_string()], Vec::new()), + }; OutOfBandEntry { source: OobSource::ReuseToml, - license: Some(license.to_string()), + licenses, copyrights: vec![], + fallback_licenses, + fallback_copyrights: vec![], + suppresses_file: precedence == Precedence::Override, precedence, + origins: vec![], } } @@ -641,7 +742,9 @@ mod tests { HeaderBlock { byte_range: (0, 0), license_ids: vec![license.to_string()], + license_spans: vec![(0, 0)], copyrights: vec![], + copyright_spans: vec![], position_after: PositionAfter::FileStart, } } @@ -681,4 +784,63 @@ mod tests { let st = state_with(vec![], Some(oob_with("CC0-1.0", Precedence::Closest))); assert_eq!(candidate_licenses(&st), vec!["CC0-1.0".to_string()]); } + + #[test] + fn multiple_blocks_reported_individually() { + // Two header blocks: both kept with their own ids; the first wins primary. + let text = + "// SPDX-License-Identifier: MIT\n\ncode\n\n// SPDX-License-Identifier: Apache-2.0\n"; + let oob = OutOfBand::default(); + let st = detect(&PathBuf::from("a.rs"), text.as_bytes(), None, &oob); + assert_eq!(st.headers.len(), 2); + assert_eq!(st.headers[0].license_ids, vec!["MIT".to_string()]); + assert_eq!(st.headers[1].license_ids, vec!["Apache-2.0".to_string()]); + assert_eq!(st.detected_license.as_deref(), Some("MIT")); + assert!(st.invalid_license_values.is_empty()); + } + + #[test] + fn crlf_headers_keep_correct_byte_ranges() { + // CRLF line endings: ranges must slice the exact source lines. + let text = "// SPDX-License-Identifier: MIT\r\n// SPDX-FileCopyrightText: 2026 Acme\r\n\r\ncode\r\n"; + let p = parse_headers(text); + assert_eq!(p.blocks.len(), 1); + let (start, end) = p.blocks[0].byte_range; + assert_eq!( + &text[start..end], + "// SPDX-License-Identifier: MIT\r\n// SPDX-FileCopyrightText: 2026 Acme" + ); + assert_eq!(p.blocks[0].license_ids, vec!["MIT".to_string()]); + assert_eq!(p.blocks[0].copyrights, vec!["2026 Acme".to_string()]); + } + + #[test] + fn invalid_values_carry_line_and_reason() { + let text = "// SPDX-License-Identifier: MIT\n// SPDX-License-Identifier: Bogus-1.0\ncode\n// SPDX-License-Identifier: Also Bad\n"; + let p = parse_headers(text); + assert_eq!(p.blocks.len(), 1); + assert_eq!(p.blocks[0].license_ids, vec!["MIT".to_string()]); + assert_eq!(p.invalid_license_values.len(), 2); + assert_eq!(p.invalid_license_values[0].line, 2); + assert_eq!(p.invalid_license_values[0].value, "Bogus-1.0"); + assert!(!p.invalid_license_values[0].reason.is_empty()); + assert_eq!(p.invalid_license_values[1].line, 4); + } + + #[test] + fn copyright_only_block_has_no_ids_but_keeps_copyright() { + let text = "// SPDX-FileCopyrightText: 2026 Acme\n"; + let oob = OutOfBand::default(); + let st = detect(&PathBuf::from("a.rs"), text.as_bytes(), None, &oob); + assert_eq!(st.detected_license, None); + assert!( + st.detected_copyrights + .iter() + .any(|c| c.contains("2026 Acme")) + ); + assert!( + st.invalid_license_values.is_empty(), + "no license tag, no diagnosis" + ); + } } diff --git a/src/domain.rs b/src/domain.rs index 4669a3a..50db5fb 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -206,8 +206,20 @@ pub struct HeaderBlock { pub byte_range: (usize, usize), /// License identifiers/expressions declared in this block. pub license_ids: Vec, + /// Value byte spans `[start, end)` of each entry in [`Self::license_ids`], + /// parallel to it: `license_spans[i]` is exactly the SPDX value bytes that + /// produced `license_ids[i]` (leading whitespace and trailing comment + /// closers excluded). Reconciliation edits these spans instead of + /// re-deriving positions from rendered text, so trailing code on the same + /// line is never reparsed or touched (FR-007). + pub license_spans: Vec<(usize, usize)>, /// `SPDX-FileCopyrightText` lines found in this block. pub copyrights: Vec, + /// Value byte spans `[start, end)` of each entry in [`Self::copyrights`], + /// parallel to it. Copyright values are never validated as SPDX (any text + /// may follow the marker), so spans additionally bound what a copyright + /// replacement may touch (FR-007, FR-009). + pub copyright_spans: Vec<(usize, usize)>, /// First-line context preceding the block. pub position_after: PositionAfter, } @@ -236,14 +248,101 @@ pub enum Precedence { Override, } -/// License/copyright covering a path from an out-of-band source (data-model §5). +impl Precedence { + /// Lower-snake string used in configuration and JSON output. + pub fn as_str(&self) -> &'static str { + match self { + Precedence::Closest => "closest", + Precedence::Aggregate => "aggregate", + Precedence::Override => "override", + } + } +} + +/// Provenance of one metadata table contributing to a file's effective +/// licensing: which document, which table, and what it contributed (FR-003a). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MetadataOrigin { + /// Repo-relative path of the metadata document (`REUSE.toml`, + /// `sub/REUSE.toml`, or `.reuse/dep5`). + pub metadata_path: PathBuf, + /// `[[annotations]]` (or dep5 paragraph) index within the document. + pub table_index: usize, + /// The table's own precedence (`dep5` is always `Aggregate`). + pub precedence: Precedence, + /// License expressions contributed by this table (validated, raw form). + pub licenses: Vec, + /// Copyright notices contributed by this table. + pub copyrights: Vec, +} + +/// License/copyright covering a path from out-of-band sources, already +/// resolved across the whole `REUSE.toml` hierarchy (data-model §5). +/// +/// Resolution mirrors the reference REUSE tool: documents are consulted from +/// the project root toward the file and stop after the first (`rootmost`) +/// `override` table; `aggregate` tables always contribute; `closest` tables +/// are a per-field fallback used only when file-level info lacks that field +/// (and, when the file carries exactly one of the two fields, supply the +/// other). `.reuse/dep5` paragraphs aggregate like any other table. #[derive(Debug, Clone, PartialEq, Eq)] pub struct OutOfBandEntry { + /// Source of the primary contributor (first override/aggregate table, or + /// the fallback table when only `closest` tables matched). pub source: OobSource, - pub license: Option, + /// Effective unconditional OOB licenses: the barrier table plus every + /// consulted `aggregate` table. Empty when no table supplied a license. + pub licenses: Vec, + /// Effective unconditional OOB copyrights (same contributors as above). pub copyrights: Vec, - /// How this entry combines with file-level info (default `Closest`). + /// Nearest-outward `closest` fallback licenses, used only when + /// file-level info carries no license for the path. + pub fallback_licenses: Vec, + /// Nearest-outward `closest` fallback copyrights, used only when + /// file-level info carries no copyright for the path. + pub fallback_copyrights: Vec, + /// True when a `rootmost` override barrier suppresses file/sidecar info + /// (and every deeper table) for this path. + pub suppresses_file: bool, + /// Governing precedence: `Override` under a barrier, else `Aggregate` + /// when any aggregate contributor exists, else `Closest`. pub precedence: Precedence, + /// Every contributing table, shallowest document first. + pub origins: Vec, +} + +impl OutOfBandEntry { + /// Canonical single-value presentation of the effective OOB licenses: + /// one expression, or the `AND`-combination of several (data-model §5). + pub fn license(&self) -> Option { + combine_licenses(&self.licenses) + } +} + +/// Combine several license expressions into one `AND` expression, parenthesizing +/// compound operands so `MIT OR Apache-2.0` plus `CC0-1.0` reads as +/// `(MIT OR Apache-2.0) AND CC0-1.0` rather than changing meaning. +pub fn combine_licenses(exprs: &[String]) -> Option { + let mut parts: Vec = Vec::new(); + for e in exprs { + let t = e.trim(); + if t.is_empty() { + continue; + } + // Any multi-token expression is parenthesized (`WITH` binds tighter + // than `AND`, so this is conservative but never changes meaning). + let needs_parens = t.chars().any(char::is_whitespace); + if needs_parens && !(t.starts_with('(') && t.ends_with(')')) { + parts.push(format!("({t})")); + } else { + parts.push(t.to_string()); + } + } + match parts.len() { + 0 => None, + 1 => Some(parts.remove(0)), + _ => Some(parts.join(" AND ")), + } } /// How `apply` covers a file that cannot carry an in-file comment header @@ -278,6 +377,19 @@ impl ActualSource { } } +/// One rejected `SPDX-License-Identifier` value: kept for diagnosis, never silently +/// dropped (F13). The line is 1-based nearest-line; columns are deliberately not +/// claimed (byte offsets shift under multibyte text). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InvalidLicenseValue { + /// 1-based nearest line number of the tag. + pub line: usize, + /// The offending tag value verbatim. + pub value: String, + /// Why it was rejected (dependency parse error, summarized). + pub reason: String, +} + /// Everything detection found for a file (data-model §5, FR-003a). #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ActualLicenseState { @@ -292,11 +404,19 @@ pub struct ActualLicenseState { /// All copyright lines found across sources. pub detected_copyrights: Vec, /// Licenses declared for in-file SPDX snippets (`SPDX-SnippetBegin`..`SPDX-SnippetEnd`). - /// These describe snippets, not the file, so they never affect drift — but their texts - /// are still referenced for `LICENSES/` completeness (FR-030). + /// These describe snippets, not the file, so they never satisfy declared + /// policy drift — but their texts are still referenced for `LICENSES/` + /// completeness (FR-030), and REUSE validation counts them like the + /// reference tool does. pub snippet_licenses: Vec, + /// Copyright notices declared inside SPDX snippet regions. Like snippet + /// licenses, these never satisfy declared policy, but REUSE validation + /// counts them toward the file's copyright requirement (reference parity). + pub snippet_copyrights: Vec, /// False when the file is not valid UTF-8 (drives `Unreadable` — FR-025). pub encoding_ok: bool, + /// Rejected license values with their location (diagnosed, never dropped). + pub invalid_license_values: Vec, } /// Exhaustive, mutually-exclusive drift classification (FR-004, data-model §6). @@ -306,6 +426,8 @@ pub enum DriftClass { Compliant, /// A header exists but the license differs from intent. WrongLicense { declared: String, actual: String }, + /// The license matches but the copyright policy is unsatisfied. + CopyrightMismatch { declared: String, actual: String }, /// Covered by intent but no header/out-of-band license present. MissingHeader, /// No rule, no default — not covered by any intent. @@ -322,6 +444,7 @@ impl DriftClass { match self { DriftClass::Compliant => "compliant", DriftClass::WrongLicense { .. } => "wrong_license", + DriftClass::CopyrightMismatch { .. } => "copyright_mismatch", DriftClass::MissingHeader => "missing_header", DriftClass::Uncovered => "uncovered", DriftClass::Excluded => "excluded", @@ -335,6 +458,104 @@ impl DriftClass { } } +/// Which content a scan evaluates: the working tree or the Git index (F06). +/// Carried through every dependent read (source bytes, sidecars, metadata, +/// configuration, license texts) so a snapshot never mixes the two. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContentSource { + /// Current working-tree bytes. + Worktree, + /// Git index blobs (`check --staged`). + Index, +} + +impl ContentSource { + /// Lower-snake label used in report `snapshot` metadata. + pub fn as_str(&self) -> &'static str { + match self { + ContentSource::Worktree => "worktree", + ContentSource::Index => "index", + } + } +} + +/// Normalize a copyright notice for comparison: trim and collapse every +/// whitespace run to a single space, so `2026 Acme` and `2026 Acme` compare +/// equal without changing what is written (FR-009). +pub fn normalize_copyright_notice(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Whether effective copyright notices satisfy a policy: `preserve` imposes no +/// requirement; `add` needs existing notices plus the requested normalized +/// notice; `replace` needs exactly the requested normalized notice. +pub fn copyright_policy_satisfied(policy: &CopyrightPolicy, effective: &[String]) -> bool { + match policy { + CopyrightPolicy::Preserve => true, + CopyrightPolicy::PreserveAndAdd(want) => { + let want = normalize_copyright_notice(want); + !effective.is_empty() + && effective + .iter() + .any(|c| normalize_copyright_notice(c) == want) + } + CopyrightPolicy::Replace(want) => { + let want = normalize_copyright_notice(want); + let mut have: Vec = effective + .iter() + .map(|c| normalize_copyright_notice(c)) + .collect(); + have.sort(); + have == vec![want] + } + } +} + +/// Whether two intents are identical: semantically equal license expressions +/// and equal copyright policies over normalized text. Equal-specificity rules +/// with differing full intent are a conflict; identical intent resolves to +/// earliest declaration order (FR-002, FR-022). +pub fn intents_equal(a: &LicenseIntent, b: &LicenseIntent) -> bool { + if !crate::spdx::expressions_equal(&a.license_expression, &b.license_expression) { + return false; + } + match (&a.copyright_policy, &b.copyright_policy) { + (CopyrightPolicy::Preserve, CopyrightPolicy::Preserve) => true, + (CopyrightPolicy::PreserveAndAdd(x), CopyrightPolicy::PreserveAndAdd(y)) + | (CopyrightPolicy::Replace(x), CopyrightPolicy::Replace(y)) => { + normalize_copyright_notice(x) == normalize_copyright_notice(y) + } + _ => false, + } +} + +/// A retained copyright mismatch: the license matched but the copyright policy +/// did not (primary drift), or neither matched (kept as a diagnostic next to +/// `WrongLicense`, data-model §6). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CopyrightDrift { + /// The policy requirement, e.g. `add:2026 Acme`. + pub declared: String, + /// Effective notices joined with `; `, or ``. + pub actual: String, +} + +impl CopyrightDrift { + pub fn new(policy: &CopyrightPolicy, effective: &[String]) -> Self { + let declared = match policy { + CopyrightPolicy::Preserve => "preserve".to_string(), + CopyrightPolicy::PreserveAndAdd(t) => format!("add:{t}"), + CopyrightPolicy::Replace(t) => format!("replace:{t}"), + }; + let actual = if effective.is_empty() { + "".to_string() + } else { + effective.join("; ") + }; + CopyrightDrift { declared, actual } + } +} + /// An unresolved equal-specificity rule conflict on one file (FR-022). #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuleConflict { @@ -354,6 +575,9 @@ pub struct FileLicensingState { pub actual: ActualLicenseState, pub drift: DriftClass, pub conflict: Option, + /// Copyright mismatch detail: primary when the license matched but the + /// copyright policy did not, diagnostic when both differ. + pub copyright_drift: Option, } /// Additive vs destructive reconciliation mode (FR-007). @@ -374,6 +598,117 @@ impl ChangeMode { } } +/// What a planned write mutates (report contract v2, FR-021). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WriteKind { + /// An in-file source edit (header insert/replace/append). + Source, + /// A `.license` sidecar body. + Sidecar, + /// A `REUSE.toml` document patch (one or more exact-path stanzas). + ReuseToml, + /// A `LICENSES/.txt` text install. + LicenseText, + /// A generated configuration file (`init`, task 8). + Config, +} + +impl WriteKind { + /// Lower-snake string used in JSON output. + pub fn as_str(&self) -> &'static str { + match self { + WriteKind::Source => "source", + WriteKind::Sidecar => "sidecar", + WriteKind::ReuseToml => "reuse_toml", + WriteKind::LicenseText => "license_text", + WriteKind::Config => "config", + } + } +} + +/// One concrete filesystem mutation, independent of file states: the same +/// records drive dry-run previews and real execution (FR-021). Byte buffers +/// stay internal; JSON serializes text/diffs only for text being written. +#[derive(Debug, Clone)] +pub struct PlannedWrite { + /// Destination written (the document itself for metadata patches). + pub path: PathBuf, + pub kind: WriteKind, + /// Expected current bytes (`None` = the destination must not exist). + /// Evaluated before every write; a mismatch blocks it. + pub before: Option>, + /// Bytes to install (`None` for blocked writes with no known result, + /// e.g. a fetch that never ran). + pub after: Option>, + /// Selected files this write covers (the assets behind a metadata patch). + pub affected_files: Vec, +} + +/// Outcome of one planned write (report contract v2, FR-021). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WriteStatus { + /// Previewed but not executed (dry-run). + Planned, + /// Bytes committed. + Applied, + /// Evaluated and left alone (converged rerun — normally absent, since + /// converged runs emit no write records at all). + Unchanged, + /// Attempted and failed; `message` says why. + Failed, + /// Refused before any attempt (a missing custom text, an unpermitted + /// fetch); `message` names the requirement. + Blocked, +} + +impl WriteStatus { + /// Lower-snake string used in JSON output. + pub fn as_str(&self) -> &'static str { + match self { + WriteStatus::Planned => "planned", + WriteStatus::Applied => "applied", + WriteStatus::Unchanged => "unchanged", + WriteStatus::Failed => "failed", + WriteStatus::Blocked => "blocked", + } + } +} + +/// One executed (or refused) write plus its outcome for the report. +#[derive(Debug, Clone)] +pub struct ExecutedWrite { + pub write: PlannedWrite, + pub status: WriteStatus, + /// Human-readable reason for `Failed`/`Blocked` (fetch URL, missing id…). + pub message: Option, + /// True when the replacement bytes were committed even though the outcome + /// is otherwise a failure (durability sync after a successful rename): + /// such a write counts as a change for partial-result accounting. + pub replacement_completed: bool, +} + +impl ExecutedWrite { + /// A previewed-but-unexecuted write (dry-run). + pub fn planned(write: PlannedWrite) -> Self { + ExecutedWrite { + write, + status: WriteStatus::Planned, + message: None, + replacement_completed: false, + } + } + + /// A refused write (missing text, unpermitted fetch). + pub fn blocked(write: PlannedWrite, message: impl Into) -> Self { + ExecutedWrite { + write, + status: WriteStatus::Blocked, + message: Some(message.into()), + replacement_completed: false, + } + } +} + /// Record of a single file's reconciliation (data-model §8). #[derive(Debug, Clone)] pub struct FileChange { diff --git a/src/engine.rs b/src/engine.rs index 3a95b63..4c4ded0 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -7,66 +7,131 @@ use std::path::{Path, PathBuf}; use rayon::prelude::*; -use crate::comment::CommentResolver; -use crate::detect::{self}; +use crate::detect; use crate::domain::{ActualLicenseState, DriftClass, FileLicensingState, Precedence}; use crate::error::Result; -use crate::report::Warning; +use crate::report::Diagnostic; use crate::report::classify::{ClassifyInput, classify}; use crate::reuse::oob::OutOfBand; use crate::rules::{Match, RuleSet}; -use crate::walk::cache::ScanCache; -use crate::walk::{self, Discovered, Selection}; +use crate::walk::{Discovered, Snapshot}; use crate::{config::LicensingConfiguration, spdx}; -/// Context for a scan: repository root and validated config. +/// Context for a scan: repository root, validated config, the content snapshot +/// every read observes, and whether declaration `[exclude]` rules apply +/// (`lint` validates REUSE coverage and never applies them). Scans are +/// stateless: every run classifies from current bytes, with no cache. pub struct Engine<'a> { pub root: PathBuf, pub config: &'a LicensingConfiguration, - pub config_text: &'a str, + pub snapshot: Snapshot, + pub honor_declaration_excludes: bool, } /// Result of a scan: classified states plus any warnings collected along the way. pub struct ScanResult { pub states: Vec, - pub warnings: Vec, + pub warnings: Vec, /// Every license identifier referenced by config or detected in files (for inventory). pub referenced_ids: BTreeSet, + /// Identifiers effectively present in evaluated files (file headers, + /// sidecars, OOB contributions, and snippet expressions) — the REUSE + /// actual inventory. Suppressed values and unused config rules are + /// excluded. + pub actual_referenced_ids: BTreeSet, + /// Identifiers declared for evaluated files (winning intents only, never + /// unmatched rules) — the policy desired set. + pub desired_referenced_ids: BTreeSet, } impl<'a> Engine<'a> { - pub fn new(root: PathBuf, config: &'a LicensingConfiguration, config_text: &'a str) -> Self { + pub fn new( + root: PathBuf, + config: &'a LicensingConfiguration, + snapshot: Snapshot, + honor_declaration_excludes: bool, + ) -> Self { Engine { root, config, - config_text, + snapshot, + honor_declaration_excludes, } } - /// Scan the selected files, classifying each. - pub fn scan(&self, selection: &Selection, cache: &mut ScanCache) -> Result { - let discovered = walk::enumerate(&self.root, selection, &self.config.exclude)?; + /// Scan the prepared files, classifying each from current bytes. + pub fn scan(&self, paths: &[Discovered]) -> Result { let rules = RuleSet::new(self.config); - let resolver = CommentResolver::new(self.config); - let oob = OutOfBand::load(&self.root); + let oob = OutOfBand::load_snapshot(&self.snapshot)?; + let excludes = if self.honor_declaration_excludes { + Some(crate::walk::build_excludes(&self.config.exclude)?) + } else { + None + }; - // Parallel detect + classify. Cache is consulted/updated sequentially afterward to - // avoid lock contention; on a warm hit the file IS still read but classification is - // trusted from the cache key (content+config+version), guaranteeing no stale verdict. - let mut results: Vec<(FileLicensingState, Option, String)> = discovered + // Parallel detect + classify over the selected files only. + let mut results: Vec<(FileLicensingState, Option)> = paths .par_iter() - .map(|d| self.classify_one(d, &rules, &resolver, &oob, cache)) + .map(|d| self.classify_one(d, &rules, &oob, excludes.as_ref())) .collect(); - // Apply cache, collect warnings and referenced ids. + // Orphan sidecars (a `.license` file whose companion is absent from the + // snapshot) are diagnosed, never silently evaluated or ignored. let mut warnings = Vec::new(); + for d in paths { + if !d + .rel_path + .extension() + .map(|e| e == "license") + .unwrap_or(false) + { + continue; + } + let companion = companion_of(&d.rel_path); + let absent = self + .snapshot + .read(&companion) + .map(|b| b.is_none()) + .unwrap_or(false); + if absent { + warnings.push(Diagnostic { + code: "orphan_sidecar".to_string(), + path: Some(d.rel_path.to_string_lossy().replace('\\', "/")), + message: format!( + "sidecar has no companion file {} in the evaluated snapshot", + companion.display() + ), + }); + } + } + + // Collect warnings and referenced ids. let mut referenced_ids = self.config_referenced_ids(); + let mut actual_referenced_ids = BTreeSet::new(); + let mut desired_referenced_ids = BTreeSet::new(); let mut states = Vec::with_capacity(results.len()); - for (state, content_hash, _drift_label) in results.drain(..) { + for (state, read_warning) in results.drain(..) { + if let Some(w) = read_warning { + warnings.push(w); + } + // A copyright mismatch next to license drift stays visible as its + // own diagnostic instead of disappearing into `wrong_license`. + if let Some(cd) = &state.copyright_drift + && matches!(state.drift, DriftClass::WrongLicense { .. }) + { + warnings.push(Diagnostic { + code: "copyright_mismatch".to_string(), + path: Some(state.path.to_string_lossy().replace('\\', "/")), + message: format!( + "copyright policy requires `{}` but found {}", + cd.declared, cd.actual + ), + }); + } // Conflict / source-override warnings. if let Some(conf) = &state.conflict { - warnings.push(Warning { - kind: "rule_conflict".to_string(), + warnings.push(Diagnostic { + code: "rule_conflict".to_string(), path: Some(state.path.to_string_lossy().replace('\\', "/")), message: conf.message.clone(), }); @@ -81,31 +146,39 @@ impl<'a> Engine<'a> { .is_some_and(|o| o.precedence == Precedence::Override) && has_header_disagreement(&state.actual) { - warnings.push(Warning { - kind: "source_override".to_string(), + warnings.push(Diagnostic { + code: "source_override".to_string(), path: Some(state.path.to_string_lossy().replace('\\', "/")), message: "in-file header disagrees with out-of-band metadata; out-of-band entry has precedence = override".to_string(), }); } if matches!(state.drift, DriftClass::Unreadable) { - warnings.push(Warning { - kind: "encoding_skipped".to_string(), + warnings.push(Diagnostic { + code: "encoding_skipped".to_string(), path: Some(state.path.to_string_lossy().replace('\\', "/")), message: "file is not valid UTF-8; skipped and counted as failure".to_string(), }); } - if let Some(l) = &state.actual.detected_license { - referenced_ids.insert(l.clone()); + // Every effective file expression counts for text inventory — + // including aggregate/fallback OOB contributions, not just the + // primary presentation value (FR-003a, FR-030). + for l in crate::detect::candidate_licenses(&state.actual) { + collect_ids(&l, &mut referenced_ids); + collect_ids(&l, &mut actual_referenced_ids); + } + // Declared intent of evaluated files only: unmatched rules never + // inflate the desired set, and excluded files are out of scope. + if !matches!(state.drift, DriftClass::Excluded) + && let Some(intent) = &state.declared_intent + { + collect_ids(&intent.license_expression, &mut referenced_ids); + collect_ids(&intent.license_expression, &mut desired_referenced_ids); } // SPDX-snippet licenses are not the file's license, but their texts must still // exist under LICENSES/ for REUSE compliance (FR-030). for s in &state.actual.snippet_licenses { collect_ids(s, &mut referenced_ids); - } - // Update cache. - if let Some(hash) = content_hash { - let rel = state.path.to_string_lossy().replace('\\', "/"); - cache.put(&rel, &hash, state.drift.as_str()); + collect_ids(s, &mut actual_referenced_ids); } states.push(state); } @@ -114,19 +187,21 @@ impl<'a> Engine<'a> { states, warnings, referenced_ids, + actual_referenced_ids, + desired_referenced_ids, }) } - /// Detect + classify a single discovered file. + /// Detect + classify a single discovered file. Snapshot read failures become + /// an `Unreadable` state with a `read_error` warning carrying path + reason + /// (F13) — never silent absence, never missing-header drift. fn classify_one( &self, d: &Discovered, rules: &RuleSet<'a>, - resolver: &CommentResolver, oob: &OutOfBand, - cache: &ScanCache, - ) -> (FileLicensingState, Option, String) { - let _ = resolver; // resolver is used by apply; kept here for symmetry. + excludes: Option<&globset::GlobSet>, + ) -> (FileLicensingState, Option) { let rel = &d.rel_path; // Resolve the rule (cheap, no IO) — done for every file so excluded files still @@ -138,7 +213,11 @@ impl<'a> Engine<'a> { Match::Conflict(c) => (None, None, Some(c)), }; - if d.excluded { + // Declaration exclusions apply to policy scans only; REUSE ignores always apply. + let declaration_excluded = excludes + .map(|set| set.is_match(rel.to_string_lossy().replace('\\', "/"))) + .unwrap_or(false); + if d.reuse_ignored || declaration_excluded { let state = classify(ClassifyInput { path: rel.clone(), matched_rule, @@ -147,26 +226,74 @@ impl<'a> Engine<'a> { conflict, excluded: true, }); - return (state, None, "excluded".to_string()); + return (state, None); } - // Read head (+ any `.license` sidecar) and detect. - let head = detect::read_head(&d.abs_path).unwrap_or_default(); - let sidecar = detect::read_sidecar(&d.abs_path); - let content_hash = ScanCache::content_hash(&head); - let _ = cache.get(rel.to_string_lossy().as_ref(), &content_hash); // hit recorded; full detail recomputed - let actual = detect::detect(rel, &head, sidecar.as_deref(), oob); + // Read through the snapshot (head + any `.license` sidecar) and detect. + // Any read failure becomes Unreadable with a path-attached reason. + let unreadable = |message: String| { + let warning = Diagnostic { + code: "read_error".to_string(), + path: Some(rel.to_string_lossy().replace('\\', "/")), + message, + }; + let state = classify(ClassifyInput { + path: rel.clone(), + matched_rule: matched_rule.clone(), + declared: declared.as_ref(), + actual: ActualLicenseState { + encoding_ok: false, + ..Default::default() + }, + conflict: conflict.clone(), + excluded: false, + }); + (state, Some(warning)) + }; + let bytes = match self.snapshot.read(rel) { + Ok(b) => b, + Err(e) => return unreadable(format!("cannot read file for evaluation: {e}")), + }; + let sidecar_rel = crate::walk::git::sidecar_for(rel); + let sidecar_bytes = match self.snapshot.read(&sidecar_rel) { + Ok(b) => b, + Err(e) => { + return unreadable(format!( + "cannot read sidecar {}: {e}", + sidecar_rel.display() + )); + } + }; + // Complete content is scanned (no head cutoff — task 3). + let full = bytes.as_deref().unwrap_or_default(); + let sidecar_opt = sidecar_bytes.as_deref(); + let actual = detect::detect(rel, full, sidecar_opt, oob); + // Rejected license values are diagnosed in-band with line + value. + let invalid_warning = if actual.invalid_license_values.is_empty() { + None + } else { + let details = actual + .invalid_license_values + .iter() + .map(|v| format!("line {}: `{}` ({})", v.line, v.value, v.reason)) + .collect::>() + .join("; "); + Some(Diagnostic { + code: "invalid_license".to_string(), + path: Some(rel.to_string_lossy().replace('\\', "/")), + message: format!("invalid SPDX-License-Identifier value: {details}"), + }) + }; let state = classify(ClassifyInput { path: rel.clone(), - matched_rule, + matched_rule: matched_rule.clone(), declared: declared.as_ref(), actual, - conflict, + conflict: conflict.clone(), excluded: false, }); - let drift_label = state.drift.as_str().to_string(); - (state, Some(content_hash), drift_label) + (state, invalid_warning) } /// Identifiers referenced by config (`default` + rules). @@ -182,46 +309,44 @@ impl<'a> Engine<'a> { } } -/// True when in-file headers and out-of-band metadata disagree on the license. +/// True when in-file headers and suppressing out-of-band metadata disagree on +/// the license (an `override` barrier makes the header ineffective). fn has_header_disagreement(actual: &ActualLicenseState) -> bool { - let header_lic = actual + let Some(o) = actual.out_of_band.as_ref() else { + return false; + }; + if !o.suppresses_file { + return false; + } + let header_lics: Vec = actual .headers .iter() .flat_map(|h| h.license_ids.clone()) - .next(); - match (&actual.out_of_band, header_lic) { - (Some(o), Some(h)) => match &o.license { - Some(ol) => !spdx::expressions_equal(ol, &h), - None => false, - }, + .collect(); + match ( + crate::domain::combine_licenses(&header_lics), + crate::domain::combine_licenses(&o.licenses), + ) { + (Some(h), Some(ol)) => !spdx::expressions_equal(&ol, &h), _ => false, } } -/// Split an SPDX expression into its constituent identifiers and collect them. -fn collect_ids(expr: &str, out: &mut BTreeSet) { - for tok in expr.split([' ', '(', ')']) { - let t = tok.trim().trim_end_matches('+'); - if t.is_empty() { - continue; - } - if t.eq_ignore_ascii_case("OR") - || t.eq_ignore_ascii_case("AND") - || t.eq_ignore_ascii_case("WITH") - { - continue; - } - out.insert(t.to_string()); +/// Split an SPDX expression into its constituent identifiers and collect them +/// (AST-based, so tabs and casing variants split correctly). +pub(crate) fn collect_ids(expr: &str, out: &mut BTreeSet) { + for id in crate::spdx::expression_ids(expr) { + out.insert(id); } } -/// Default cache path. Stored inside `.git/` when present so it never dirties the working -/// tree (which would otherwise block `apply`'s clean-tree guard); falls back to the root. -pub fn default_cache_path(root: &Path) -> PathBuf { - let git_dir = root.join(".git"); - if git_dir.is_dir() { - git_dir.join("licet-cache") - } else { - root.join(".licet-cache") +/// Strip a trailing `.license` sidecar suffix to get the companion asset path. +fn companion_of(sidecar: &Path) -> PathBuf { + let name = sidecar + .file_name() + .map(|n| n.to_string_lossy().into_owned()); + match name.and_then(|n| n.strip_suffix(".license").map(str::to_string)) { + Some(base) => sidecar.with_file_name(base), + None => sidecar.to_path_buf(), } } diff --git a/src/error.rs b/src/error.rs index 4c54a72..c8bd036 100644 --- a/src/error.rs +++ b/src/error.rs @@ -22,6 +22,22 @@ impl ExitCode { } } +/// Derive the single truthful `apply` outcome from one observation triple. +/// The same logic drives the summary and the process exit (FR-021): a run +/// that changed files but hit an operational failure is [`ExitCode::Partial`]; +/// any operational failure *or* remaining violation without changes is +/// [`ExitCode::Violations`]; only a clean, complete run is +/// [`ExitCode::Success`]. In particular, writes that all succeed but leave +/// declaration drift (additive contradictions, unfixable entries) are exit 1, +/// not partial — partial means the tool itself failed partway. +pub fn apply_exit(changed: usize, operational_failure: bool, violations: bool) -> ExitCode { + match (changed > 0, operational_failure, violations) { + (true, true, _) => ExitCode::Partial, + (_, true, _) | (_, false, true) => ExitCode::Violations, + (_, false, false) => ExitCode::Success, + } +} + /// Library-level errors. The binary wraps these with `anyhow` and maps to [`ExitCode`]. #[derive(Debug, Error)] pub enum LicetError { @@ -37,6 +53,17 @@ pub enum LicetError { #[error("git error: {0}")] Git(String), + /// Contained filesystem write failure. + #[error("{0}")] + Write(#[from] crate::reuse::atomic::WriteError), + + /// License-text materialization failure (invalid ids → exit 2 via [`ExitCode`]). + #[error("{0}")] + Materialize(#[from] crate::reuse::inventory::MaterializeError), + /// License-text inventory failure (duplicate ids, snapshot gaps → exit 2). + #[error("{0}")] + Inventory(#[from] crate::reuse::inventory::InventoryError), + /// Internal invariant violation. #[error("{0}")] Internal(String), diff --git a/src/exec.rs b/src/exec.rs new file mode 100644 index 0000000..c436101 --- /dev/null +++ b/src/exec.rs @@ -0,0 +1,162 @@ +//! Deterministic single-file write execution (FR-021). +//! +//! [`execute_writes`] carries out [`PlannedWrite`]s in deterministic +//! destination order through the shared safe writer, recording every attempt +//! and failure. Metadata-document batches (`REUSE.toml`) stay with the caller, +//! which groups them per document; everything here is one destination, one +//! atomic write. The same [`PlannedWrite`] records drive dry-run previews +//! (reported `Planned`, never executed) and real runs. + +use std::path::Path; + +use crate::domain::{ExecutedWrite, PlannedWrite, WriteStatus}; + +/// Execute planned single-file writes in deterministic destination order +/// (byte-wise path sort; stable within one path). +/// +/// Every write is attempted independently: one destination's failure is +/// recorded on that write and never blocks the others. A committed +/// replacement that fails only its durability sync reports `Failed` with +/// `replacement_completed` set, so callers still count it as a change. +pub fn execute_writes(root: &Path, writes: &[PlannedWrite]) -> Vec { + let mut order: Vec = (0..writes.len()).collect(); + order.sort_by(|&a, &b| writes[a].path.cmp(&writes[b].path)); + order + .into_iter() + .map(|i| execute_one(root, &writes[i])) + .collect() +} + +fn execute_one(root: &Path, write: &PlannedWrite) -> ExecutedWrite { + // Create exactly the destination's own ancestor chain (e.g. `LICENSES/`); + // nothing beyond what the write requires. + if let Some(parent) = root.join(&write.path).parent() + && !parent.as_os_str().is_empty() + && let Err(e) = std::fs::create_dir_all(parent) + { + return ExecutedWrite { + write: write.clone(), + status: WriteStatus::Failed, + message: Some(format!( + "cannot create parent directory {}: {e}", + parent.display() + )), + replacement_completed: false, + }; + } + let after = match &write.after { + Some(bytes) => bytes, + None => { + return ExecutedWrite::blocked(write.clone(), "no bytes were planned for this write"); + } + }; + match crate::reuse::atomic_write(root, &write.path, write.before.as_deref(), after) { + Ok(()) => ExecutedWrite { + write: write.clone(), + status: WriteStatus::Applied, + message: None, + replacement_completed: true, + }, + Err(e) => ExecutedWrite { + write: write.clone(), + status: WriteStatus::Failed, + message: Some(e.to_string()), + replacement_completed: e.replacement_completed, + }, + } +} + +// REUSE-IgnoreStart — SPDX tags in the tests below are fixtures, not this file's licensing. +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::WriteKind; + + fn write(rel: &str, before: Option<&[u8]>, after: &[u8]) -> PlannedWrite { + PlannedWrite { + path: std::path::PathBuf::from(rel), + kind: WriteKind::Source, + before: before.map(|b| b.to_vec()), + after: Some(after.to_vec()), + affected_files: vec![std::path::PathBuf::from(rel)], + } + } + + #[test] + fn executes_in_destination_order() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let outcomes = execute_writes( + root, + &[ + write("b.txt", None, b"new-b"), + write("a.txt", None, b"new-a"), + ], + ); + assert_eq!(outcomes.len(), 2); + assert!(outcomes.iter().all(|o| o.status == WriteStatus::Applied)); + // Deterministic destination order regardless of plan order. + let paths: Vec<_> = outcomes.iter().map(|o| o.write.path.clone()).collect(); + assert_eq!( + paths, + vec![ + std::path::PathBuf::from("a.txt"), + std::path::PathBuf::from("b.txt") + ] + ); + assert_eq!(std::fs::read(root.join("a.txt")).unwrap(), b"new-a"); + assert_eq!(std::fs::read(root.join("b.txt")).unwrap(), b"new-b"); + } + + #[test] + fn expected_guard_mismatch_fails_without_touching_bytes() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("a.txt"), b"actual").unwrap(); + let outcomes = execute_writes(root, &[write("a.txt", Some(b"stale"), b"new")]); + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].status, WriteStatus::Failed); + assert!(!outcomes[0].replacement_completed); + assert!( + !outcomes[0] + .message + .as_deref() + .unwrap_or_default() + .is_empty() + ); + assert_eq!(std::fs::read(root.join("a.txt")).unwrap(), b"actual"); + } + + #[test] + fn destination_turned_directory_between_plan_and_execution_fails() { + // Deterministic partial-failure repro without permission tricks: the + // plan is computed against a file that becomes a directory before the + // executor runs. No production test flags are involved. + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("ok.txt"), b"old-ok").unwrap(); + std::fs::write(root.join("gone.txt"), b"old-gone").unwrap(); + let planned = vec![ + write("ok.txt", Some(b"old-ok"), b"new-ok"), + write("gone.txt", Some(b"old-gone"), b"new-gone"), + ]; + std::fs::remove_file(root.join("gone.txt")).unwrap(); + std::fs::create_dir(root.join("gone.txt")).unwrap(); + let outcomes = execute_writes(root, &planned); + assert_eq!(outcomes.len(), 2); + let ok = outcomes + .iter() + .find(|o| o.write.path == std::path::Path::new("ok.txt")) + .unwrap(); + let gone = outcomes + .iter() + .find(|o| o.write.path == std::path::Path::new("gone.txt")) + .unwrap(); + assert_eq!(ok.status, WriteStatus::Applied); + assert_eq!(std::fs::read(root.join("ok.txt")).unwrap(), b"new-ok"); + assert_eq!(gone.status, WriteStatus::Failed); + assert!(!gone.replacement_completed); + assert!(root.join("gone.txt").is_dir(), "failed write left alone"); + } +} +// REUSE-IgnoreEnd diff --git a/src/lib.rs b/src/lib.rs index afd4424..c02f6e0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod detect; pub mod domain; pub mod engine; pub mod error; +pub mod exec; pub mod reconcile; pub mod report; pub mod reuse; diff --git a/src/reconcile/insert.rs b/src/reconcile/insert.rs index c62758e..51500c4 100644 --- a/src/reconcile/insert.rs +++ b/src/reconcile/insert.rs @@ -42,13 +42,33 @@ pub fn insertion_offset(content: &str) -> usize { offset += line_len(&bytes[offset..]); } - // Encoding / XML declaration on the (new) first line. + // A `` processing instruction (XML declaration, PHP open tag) + // yields only through its close, so a same-line body stays after the + // header; other encoding declarations take the whole first line. let rest = &content[offset..]; let first_line_end = line_len(rest.as_bytes()); let first_line = &rest[..first_line_end]; - if first_line.starts_with("") { + Some(i) => offset += i + "?>".len(), + None => offset += first_line_end, + } + } else if first_line.contains("coding:") || first_line.contains("coding=") { + offset += first_line_end; + } + + // Language-specific first lines that must stay first (FR-019): the PHP + // open tag, the Cabal project header, and TeX/BibTeX magic comments — the + // same positions `leading_position` recognizes for detection. + let rest = &content[offset..]; + let first_line_end = line_len(rest.as_bytes()); + let first_line = &rest[..first_line_end]; + if first_line.starts_with("\n", + "\n", + ); + assert_eq!( + out, + "\n\n\n\n" + ); + } + + #[test] + fn inserts_after_shebang_and_encoding_decl() { + let out = insert_header( + "#!/usr/bin/env python\n# coding: utf-8\nprint('hi')\n", + "# SPDX-License-Identifier: MIT\n", + ); + assert_eq!( + out, + "#!/usr/bin/env python\n# coding: utf-8\n# SPDX-License-Identifier: MIT\n\nprint('hi')\n" + ); + } + + #[test] + fn bom_then_shebang_stay_contiguous() { + let out = insert_header( + "\u{feff}#!/bin/sh\necho hi\n", + "# SPDX-License-Identifier: MIT\n", + ); + assert_eq!( + out, + "\u{feff}#!/bin/sh\n# SPDX-License-Identifier: MIT\n\necho hi\n" + ); + } + + #[test] + fn no_trailing_newline_gets_terminated_body() { + let out = insert_header("code", "// SPDX-License-Identifier: MIT\n"); + assert_eq!(out, "// SPDX-License-Identifier: MIT\n\ncode"); + } + + #[test] + fn unicode_body_survives_insertion() { + let out = insert_header( + "fn héllo(){} // héllo\n", + "// SPDX-License-Identifier: MIT\n", + ); + assert_eq!( + out, + "// SPDX-License-Identifier: MIT\n\nfn héllo(){} // héllo\n" + ); + } + + #[test] + fn crlf_shebang_keeps_crlf() { + let out = insert_header("#!/bin/sh\r\ncode\r\n", "# SPDX-License-Identifier: MIT\n"); + assert_eq!( + out, + "#!/bin/sh\r\n# SPDX-License-Identifier: MIT\r\n\r\ncode\r\n" + ); + } + + #[test] + fn multiline_block_header_inserts_verbatim() { + let out = insert_header("code\n", "/*\n * SPDX-License-Identifier: MIT\n */\n"); + assert_eq!(out, "/*\n * SPDX-License-Identifier: MIT\n */\n\ncode\n"); + } } // REUSE-IgnoreEnd diff --git a/src/reconcile/mod.rs b/src/reconcile/mod.rs index fee4e2a..4793c8e 100644 --- a/src/reconcile/mod.rs +++ b/src/reconcile/mod.rs @@ -6,7 +6,7 @@ pub mod insert; use crate::comment; use crate::detect::candidate_licenses; use crate::domain::{ - ActualLicenseState, ChangeMode, CommentSyntax, CopyrightPolicy, LicenseIntent, + ActualLicenseState, ChangeMode, CommentSyntax, CopyrightPolicy, HeaderBlock, LicenseIntent, }; use crate::spdx; @@ -36,7 +36,52 @@ impl PlannedChange { } } +/// How [`plan_file`] can fail instead of producing an edit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlanErrorKind { + /// A human must intervene: a tag sits in program text rather than a + /// comment, or the target block carries conflicting licenses. The caller + /// reports an actionable `unfixable` diagnostic and writes nothing. + Unfixable, + /// The plan target is internally inconsistent (stale spans, a range outside + /// the content). The caller reports a write failure rather than guessing. + InvalidTarget, +} + +/// A refused plan: kind, actionable message, and the 1-based line of the +/// offending tag when known. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlanError { + pub kind: PlanErrorKind, + pub message: String, + pub line: Option, +} + +impl PlanError { + fn unfixable(message: impl Into, line: Option) -> Self { + PlanError { + kind: PlanErrorKind::Unfixable, + message: message.into(), + line, + } + } + + fn invalid(message: impl Into) -> Self { + PlanError { + kind: PlanErrorKind::InvalidTarget, + message: message.into(), + line: None, + } + } +} + /// Plan a file's reconciliation toward `intent` in the given `style`. +/// +/// Edits are span-based: license values are replaced at the exact byte spans +/// carried from detection, and appended lines are anchored to existing tag +/// lines with closers derived from `style` — never from re-parsed trailing +/// code. Copyright lines are never touched (FR-009). Returns [`PlanError`] +/// instead of guessing when the target block cannot be edited safely. pub fn plan_file( content: &str, actual: &ActualLicenseState, @@ -44,62 +89,264 @@ pub fn plan_file( style: &CommentSyntax, mode: ChangeMode, target_header: Option, -) -> PlannedChange { - // Already compliant → nothing to do. +) -> Result { let candidates = candidate_licenses(actual); - let already = candidates + let license_ok = candidates .iter() .any(|c| spdx::expressions_equal(c, &intent.license_expression)); - if already { - return PlannedChange::noop(mode); - } - // No in-file header → insert one. + // No in-file header → insert one carrying the full intent. if actual.headers.is_empty() { + if license_ok { + // Covered out-of-band: nothing in-file to do. + return Ok(PlannedChange::noop(mode)); + } let copyrights = copyrights_to_write(&[], &intent.copyright_policy); let header = comment::render_header(style, &intent.license_expression, ©rights); let new_content = insert::insert_header(content, &header); - return PlannedChange { + return Ok(PlannedChange { new_content: Some(new_content), mode, target_header: None, wrote_header: true, preserved_copyrights: copyrights.len(), contradiction: false, - }; + }); } - // Edit an existing header block. - let idx = target_header.unwrap_or(0).min(actual.headers.len() - 1); + // Edit an existing header block. An explicit index is validated, never + // clamped: silently editing a different block than requested corrupts. + let idx = match target_header { + Some(t) if t >= actual.headers.len() => { + return Err(PlanError::invalid(format!( + "target header #{t} is out of range (file has {} header block(s))", + actual.headers.len() + ))); + } + Some(t) => t, + None => 0, + }; let block = &actual.headers[idx]; let (start, end) = block.byte_range; - let block_text = &content[start..end]; + if end > content.len() || start > end { + return Err(PlanError::invalid(format!( + "header #{idx} byte range ({start}, {end}) is outside the file; re-run" + ))); + } + if block.license_ids.len() != block.license_spans.len() + || block.copyrights.len() != block.copyright_spans.len() + { + return Err(PlanError::invalid(format!( + "header #{idx} has {} license / {} copyright values but {} / {} recorded spans", + block.license_ids.len(), + block.copyrights.len(), + block.license_spans.len(), + block.copyright_spans.len() + ))); + } let preserved = block.copyrights.len(); - let new_block = match mode { - ChangeMode::Destructive => replace_license_in_block(block_text, &intent.license_expression), - ChangeMode::Additive => add_license_to_block(block_text, &intent.license_expression), + // Copyright intent applies even when the license already matches (FR-007): + // a missing requested notice is a real change, not a no-op. + let desired_cprs = copyrights_to_write(&block.copyrights, &intent.copyright_policy); + let cpr_edit = plan_copyright_edit(block, idx, &desired_cprs, content)?; + + // Every span must still address the value it was recorded for. + for (id, span) in block.license_ids.iter().zip(&block.license_spans) { + check_span(content, idx, start, end, "license", id, *span)?; + } + if let CprEdit::Swap(i) = cpr_edit { + check_span( + content, + idx, + start, + end, + "copyright", + &block.copyrights[i], + block.copyright_spans[i], + )?; + } + + // Collect every span replacement (license values, plus a single-copyright + // swap) for one descending pass so offsets never interfere. + let mut reps: Vec<((usize, usize), String)> = Vec::new(); + // How a still-missing license line is added, if at all. + enum LicenseInsert { + None, + /// After the block's last license line (additive over license drift). + AfterLicense, + /// After the block's last line (a license-less block gains its record). + AtEnd, + } + let mut license_insert_kind = LicenseInsert::None; + if !license_ok { + if block.license_ids.is_empty() { + // An in-file record would duplicate out-of-band coverage, so a + // license-less block gains its line only when the license is + // unsatisfied (FR-003a). + license_insert_kind = LicenseInsert::AtEnd; + } else if mode == ChangeMode::Destructive { + // Replacing several *distinct* licenses would silently resolve a + // conflict the classifier surfaced — refuse instead. + if block + .license_ids + .iter() + .skip(1) + .any(|l| !spdx::expressions_equal(l, &block.license_ids[0])) + { + return Err(PlanError::unfixable( + format!( + "header #{idx} declares conflicting licenses ({})", + block.license_ids.join(", ") + ), + block + .license_spans + .first() + .map(|sp| line_for_offset(content, sp.0)), + )); + } + for span in &block.license_spans { + check_editable(content, span.0, style)?; + reps.push((*span, intent.license_expression.clone())); + } + } else { + license_insert_kind = LicenseInsert::AfterLicense; + } + } + if let CprEdit::Swap(i) = cpr_edit { + check_editable(content, block.copyright_spans[i].0, style)?; + reps.push((block.copyright_spans[i], desired_cprs[0].clone())); + } + + let mut out = content.to_string(); + apply_spans(&mut out, &reps); + // Net length change at or before a point, for re-anchoring appends. All + // replacements lie inside the block by validation. + let shift = |at: usize| -> usize { + (at as i64 + + reps + .iter() + .filter(|(sp, _)| sp.1 <= at) + .map(|(sp, text)| text.len() as i64 - (sp.1 - sp.0) as i64) + .sum::()) as usize }; + // Anchored inserts, computed against the post-replacement string. The + // license insert is pushed before copyright inserts so that a shared point + // renders copyright lines first per REUSE convention (stable order: the + // license text is spliced first, then copyright lines land before it). + let mut pending: Vec<(usize, String)> = Vec::new(); + let license_tag = format!("SPDX-License-Identifier: {}", intent.license_expression); + match license_insert_kind { + LicenseInsert::AfterLicense => { + // The new record goes after the block's last license line, so a + // same-line closer is handled with a style-derived suffix and a + // later-line closer keeps the insert before it. Existing lines are + // only read for anchoring, never rewritten. + let last = block.license_spans[block.license_spans.len() - 1]; + let anchor = license_anchor(&out, (shift(last.0), shift(last.0)), style)?; + pending.push(( + anchor.insert_at, + anchor_tag_line(&anchor, style, &license_tag), + )); + } + LicenseInsert::AtEnd => { + let (anchor, tag_at) = block_anchor(&out, start, shift(end), style); + check_editable(&out, tag_at, style)?; + pending.push(( + anchor.insert_at, + anchor_tag_line(&anchor, style, &license_tag), + )); + } + LicenseInsert::None => {} + } + if matches!(cpr_edit, CprEdit::Append) { + let (anchor, tag_at) = block_anchor(&out, start, shift(end), style); + check_editable(&out, tag_at, style)?; + let mut text = String::new(); + for notice in missing_notices(&block.copyrights, &desired_cprs) { + text.push_str(&anchor_tag_line( + &anchor, + style, + &format!("SPDX-FileCopyrightText: {notice}"), + )); + } + pending.push((anchor.insert_at, text)); + } + pending.sort_by_key(|(at, _)| std::cmp::Reverse(*at)); + for (at, text) in pending { + out = splice_insert(&out, at, &text); + } + + if license_ok && matches!(cpr_edit, CprEdit::None) { + return Ok(PlannedChange::noop(mode)); + } + let contradiction = mode == ChangeMode::Additive && block .license_ids .iter() .any(|l| !spdx::expressions_equal(l, &intent.license_expression)); - let mut new_content = String::with_capacity(content.len() + 32); - new_content.push_str(&content[..start]); - new_content.push_str(&new_block); - new_content.push_str(&content[end..]); - - PlannedChange { - new_content: Some(new_content), + Ok(PlannedChange { + new_content: Some(out), mode, target_header: Some(idx), wrote_header: false, preserved_copyrights: preserved, contradiction, + }) +} + +/// The copyright half of a block plan: nothing, a single value swap (by index +/// into the block's copyrights), or appended notice lines (FR-007, FR-009). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CprEdit { + None, + Swap(usize), + Append, +} + +/// Decide the copyright edit from the policy-desired notices versus the +/// block's own. Reducing several notices to one exact notice is refused: no +/// single span covers the surplus lines, and deleting comment lines the tool +/// did not render risks trailing code. +fn plan_copyright_edit( + block: &HeaderBlock, + idx: usize, + desired: &[String], + content: &str, +) -> Result { + if *desired == *block.copyrights { + return Ok(CprEdit::None); + } + if block.copyrights.len() == 1 && desired.len() == 1 { + return Ok(CprEdit::Swap(0)); + } + if desired.len() <= block.copyrights.len() { + return Err(PlanError::unfixable( + format!( + "header #{idx} carries {} copyright notices but policy requires exactly `{}`; \ + reduce to one notice manually", + block.copyrights.len(), + desired.join(", "), + ), + block + .copyright_spans + .first() + .map(|sp| line_for_offset(content, sp.0)), + )); } + Ok(CprEdit::Append) +} + +/// Notices in `desired` absent from `existing`, in order. +fn missing_notices(existing: &[String], desired: &[String]) -> Vec { + desired + .iter() + .filter(|d| !existing.iter().any(|e| e == *d)) + .cloned() + .collect() } /// Decide which copyright lines to write for a brand-new header given the policy. @@ -117,72 +364,214 @@ pub(crate) fn copyrights_to_write(existing: &[String], policy: &CopyrightPolicy) } } -/// Replace the `SPDX-License-Identifier` value(s) in a block, preserving comment prefixes -/// and all copyright lines (destructive default, FR-007 + FR-009). -fn replace_license_in_block(block: &str, new_license: &str) -> String { - let ending = if block.contains("\r\n") { "\r\n" } else { "\n" }; - let trailing_newline = block.ends_with('\n'); - let mut out_lines: Vec = Vec::new(); - for line in block.split_inclusive('\n') { - let trimmed = line.trim_end_matches(['\n', '\r']); - if let Some(pos) = trimmed.find("SPDX-License-Identifier:") { - let (prefix, rest) = trimmed.split_at(pos + "SPDX-License-Identifier:".len()); - // Preserve any trailing block terminator (e.g. ` -->`, ` */`). - let terminator = extract_terminator(rest); - out_lines.push(format!("{prefix} {new_license}{terminator}")); - } else { - out_lines.push(trimmed.to_string()); +/// Where a new license line goes, and what it mirrors. +struct Anchor { + /// Byte offset (at a line boundary) where the new line is inserted. + insert_at: usize, + /// Comment prefix mirrored from the anchor line (its own validated marker). + prefix: String, + /// Whether the anchor line ends with the style's block closer, in which + /// case the new line carries the same closer and stays self-contained. + same_line_closer: bool, + /// Newline convention of the anchor line. + ending: &'static str, +} + +/// Refuse tags that sit in ordinary program text rather than a comment: the +/// text between the line start and the tag must be blank or carry one of the +/// style's comment markers (FR-007). Line openers and block openers match +/// anywhere in the prefix (`code(); /* tag */`, ` Result<(), PlanError> { + if editable_context(content, value_at, style) { + return Ok(()); + } + let line = line_for_offset(content, value_at); + Err(PlanError::unfixable( + format!( + "SPDX tag on line {line} sits in program text, not a {} comment; move it into a \ + comment or cover the file out-of-band", + style_family(style), + ), + Some(line), + )) +} + +fn editable_context(content: &str, value_at: usize, style: &CommentSyntax) -> bool { + let line_start = content[..value_at].rfind('\n').map(|i| i + 1).unwrap_or(0); + let infix = content[line_start..value_at].trim(); + if infix.is_empty() { + // A bare tag line can only be a block-comment interior (which needs no + // per-line marker); under a line-only style it is program text — a + // Makefile target, an assignment — and must not be rewritten. + return style.block().is_some(); + } + if style + .line() + .is_some_and(|l| infix.contains(l.prefix.as_str())) + { + return true; + } + if let Some(b) = style.block() { + if infix.contains(b.open.as_str()) { + return true; + } + let interior = b.line_prefix.trim(); + if !interior.is_empty() && infix.trim_start().starts_with(interior) { + return true; } } - let mut joined = out_lines.join(ending); - if trailing_newline { - joined.push_str(ending); + false +} + +/// Anchor an additive append after the last license line: the insert lands +/// before any later-line closing delimiter, and a same-line closer is replayed +/// from the style so the new line is self-contained (FR-006). +fn license_anchor( + content: &str, + value_span: (usize, usize), + style: &CommentSyntax, +) -> Result { + check_editable(content, value_span.0, style)?; + let line_start = content[..value_span.0] + .rfind('\n') + .map(|i| i + 1) + .unwrap_or(0); + let line_end = content[value_span.0..] + .find('\n') + .map(|i| value_span.0 + i + 1) + .unwrap_or(content.len()); + let line = &content[line_start..line_end]; + let stripped = line.trim_end_matches(['\n', '\r']); + // The value span sits after the license marker, so the prefix is everything + // before the marker on this line. + let marker_at = stripped.find("SPDX-License-Identifier:").unwrap_or(0); + let prefix = stripped[..marker_at].to_string(); + let same_line_closer = style + .block() + .is_some_and(|b| stripped.trim_end().ends_with(b.close.as_str())); + let ending = if line.contains("\r\n") { "\r\n" } else { "\n" }; + Ok(Anchor { + insert_at: line_end, + prefix, + same_line_closer, + ending, + }) +} + +/// A recorded value span must still address the value it was recorded for; +/// otherwise the plan target is stale and the caller must re-run, not guess. +fn check_span( + content: &str, + idx: usize, + start: usize, + end: usize, + kind: &str, + value: &str, + span: (usize, usize), +) -> Result<(), PlanError> { + let (s, e) = span; + if e > content.len() || s > e || s < start || e > end || &content[s..e] != value { + return Err(PlanError::invalid(format!( + "header #{idx} {kind} span ({s}, {e}) no longer holds `{value}`; re-run" + ))); } - joined + Ok(()) } -/// Add a new license line to a block without removing existing ones (additive, FR-006). -fn add_license_to_block(block: &str, new_license: &str) -> String { - let ending = if block.contains("\r\n") { "\r\n" } else { "\n" }; - let trailing_newline = block.ends_with('\n'); - let mut out_lines: Vec = Vec::new(); - let mut inserted = false; - for line in block.split_inclusive('\n') { - let trimmed = line.trim_end_matches(['\n', '\r']); - out_lines.push(trimmed.to_string()); - if !inserted && let Some(pos) = trimmed.find("SPDX-License-Identifier:") { - // Mirror the existing line's comment prefix. - let prefix = &trimmed[..pos]; - let terminator = extract_terminator(&trimmed[pos + "SPDX-License-Identifier:".len()..]); - out_lines.push(format!( - "{prefix}SPDX-License-Identifier: {new_license}{terminator}" - )); - inserted = true; - } +/// Apply disjoint span replacements in descending offset order so earlier +/// offsets stay valid throughout. +fn apply_spans(out: &mut String, reps: &[((usize, usize), String)]) { + let mut order: Vec = (0..reps.len()).collect(); + order.sort_by_key(|&i| std::cmp::Reverse(reps[i].0.0)); + for i in order { + let ((s, e), text) = &reps[i]; + out.replace_range(*s..*e, text); } - let mut joined = out_lines.join(ending); - if trailing_newline { - joined.push_str(ending); +} + +/// Anchor for appending after a block's last line: mirror that line's prefix. +/// Returns the anchor plus the tag offset used for the editability check. +fn block_anchor(content: &str, start: usize, end: usize, style: &CommentSyntax) -> (Anchor, usize) { + let text = &content[start..end]; + let last_line = text.lines().next_back().unwrap_or(""); + let marker_at = last_line + .find("SPDX-License-Identifier:") + .or_else(|| last_line.find("SPDX-FileCopyrightText:")) + .unwrap_or(0); + let prefix = last_line[..marker_at].to_string(); + let ending = if text.contains("\r\n") { "\r\n" } else { "\n" }; + let tag_at = start + text.len() - last_line.len() + marker_at; + // The block range ends at the last tag line's final byte (detection + // excludes the terminator): the insert goes after that terminator so the + // new line does not fuse with the anchor line. + let mut insert_at = end; + if content[insert_at..].starts_with("\r\n") { + insert_at += 2; + } else if content[insert_at..].starts_with('\n') { + insert_at += 1; } - joined + // A self-closed anchor line (`/* … */`) replays the style's closer on the + // new line; without it the rest of the file would become a comment. + let same_line_closer = style + .block() + .is_some_and(|b| last_line.trim_end().ends_with(b.close.as_str())); + ( + Anchor { + insert_at, + prefix, + same_line_closer, + ending, + }, + tag_at, + ) } -/// Extract a trailing block-comment terminator (` -->`, ` */`) from a license value, if any. -fn extract_terminator(value: &str) -> String { - let v = value.trim_end(); - for term in ["-->", "*/"] { - if v.ends_with(term) { - return format!(" {term}"); - } +/// Render an appended tag line from the anchor: mirrored prefix plus the +/// style-derived closer when the anchor line carries one. +fn anchor_tag_line(anchor: &Anchor, style: &CommentSyntax, tag: &str) -> String { + let mut line = format!("{}{tag}", anchor.prefix); + if anchor.same_line_closer + && let Some(b) = style.block() + { + line.push(' '); + line.push_str(b.close.as_str()); + } + line.push_str(anchor.ending); + line +} + +/// Splice `insert` into `content` at `at` (a line boundary). +fn splice_insert(content: &str, at: usize, insert: &str) -> String { + let mut out = String::with_capacity(content.len() + insert.len()); + out.push_str(&content[..at]); + out.push_str(insert); + out.push_str(&content[at..]); + out +} + +/// 1-based line number of a byte offset. +fn line_for_offset(content: &str, at: usize) -> usize { + content[..at.min(content.len())].matches('\n').count() + 1 +} + +/// Short human label for the expected comment family (diagnostics only). +fn style_family(style: &CommentSyntax) -> &'static str { + match style { + CommentSyntax::LineOnly(_) => "line", + CommentSyntax::BlockOnly(_) => "block", + CommentSyntax::Both { .. } => "line or block", } - String::new() } // REUSE-IgnoreStart — SPDX tags in the tests below are fixtures, not this file's licensing. #[cfg(test)] mod tests { use super::*; - use crate::domain::{ActualSource, HeaderBlock, PositionAfter}; + use crate::domain::ActualLicenseState; + use crate::reuse::oob::OutOfBand; + use std::path::Path; fn intent(expr: &str) -> LicenseIntent { LicenseIntent { @@ -191,41 +580,40 @@ mod tests { } } - fn state_with_header( + fn intent_cpr(expr: &str, policy: CopyrightPolicy) -> LicenseIntent { + LicenseIntent { + license_expression: expr.to_string(), + copyright_policy: policy, + } + } + + /// Real detection state, so spans match the content exactly as in production. + fn detected(content: &str) -> ActualLicenseState { + let oob = OutOfBand::default(); + crate::detect::detect(Path::new("t.rs"), content.as_bytes(), None, &oob) + } + + fn plan( content: &str, + actual: &ActualLicenseState, license: &str, - copyrights: Vec, - ) -> ActualLicenseState { - // byte range spanning the two header lines. - let end = content.find("\n\n").map(|i| i + 1).unwrap_or(content.len()); - ActualLicenseState { - headers: vec![HeaderBlock { - byte_range: (0, end), - license_ids: vec![license.to_string()], - copyrights, - position_after: PositionAfter::FileStart, - }], - out_of_band: None, - detected_license: Some(license.to_string()), - detected_source: Some(ActualSource::Header), - detected_copyrights: vec![], - snippet_licenses: vec![], - encoding_ok: true, - } + style: &CommentSyntax, + mode: ChangeMode, + ) -> Result { + plan_file(content, actual, &intent(license), style, mode, None) } #[test] fn destructive_replaces_license_preserves_copyright() { let content = "// SPDX-FileCopyrightText: 2026 Acme\n// SPDX-License-Identifier: Apache-2.0\n\ncode\n"; - let actual = state_with_header(content, "Apache-2.0", vec!["2026 Acme".to_string()]); - let plan = plan_file( + let plan = plan( content, - &actual, - &intent("MIT"), + &detected(content), + "MIT", &CommentSyntax::line_only("//"), ChangeMode::Destructive, - None, - ); + ) + .unwrap(); let new = plan.new_content.unwrap(); assert!(new.contains("SPDX-License-Identifier: MIT")); assert!(new.contains("SPDX-FileCopyrightText: 2026 Acme")); @@ -233,24 +621,140 @@ mod tests { assert_eq!(plan.preserved_copyrights, 1); } + #[test] + fn destructive_span_edit_preserves_surrounding_code() { + // Only the value bytes change: the code prefix and the same-line + // closer survive byte-for-byte. + let content = "code(); /* SPDX-License-Identifier: MIT */\n"; + let plan = plan( + content, + &detected(content), + "Apache-2.0", + &CommentSyntax::both("//", "/*", "*/", " * "), + ChangeMode::Destructive, + ) + .unwrap(); + assert_eq!( + plan.new_content.unwrap(), + "code(); /* SPDX-License-Identifier: Apache-2.0 */\n" + ); + } + + #[test] + fn destructive_program_text_tag_is_unfixable() { + let content = "FOO=SPDX-License-Identifier: Apache-2.0\n"; + let err = plan( + content, + &detected(content), + "MIT", + &CommentSyntax::both("//", "/*", "*/", " * "), + ChangeMode::Destructive, + ) + .unwrap_err(); + assert_eq!(err.kind, PlanErrorKind::Unfixable); + assert_eq!(err.line, Some(1)); + } + + #[test] + fn destructive_bare_tag_under_line_style_is_unfixable() { + // A bare tag line is a Makefile target, not a comment. + let content = "SPDX-License-Identifier: Apache-2.0\n"; + let err = plan( + content, + &detected(content), + "MIT", + &CommentSyntax::line_only("#"), + ChangeMode::Destructive, + ) + .unwrap_err(); + assert_eq!(err.kind, PlanErrorKind::Unfixable); + } + + #[test] + fn destructive_conflicting_licenses_are_unfixable() { + let content = + "// SPDX-License-Identifier: MIT\n// SPDX-License-Identifier: Apache-2.0\n\ncode\n"; + let err = plan( + content, + &detected(content), + "CC0-1.0", + &CommentSyntax::line_only("//"), + ChangeMode::Destructive, + ) + .unwrap_err(); + assert_eq!(err.kind, PlanErrorKind::Unfixable); + assert_eq!(err.line, Some(1)); + } + #[test] fn additive_keeps_both_and_flags_contradiction() { let content = "// SPDX-License-Identifier: Apache-2.0\n\ncode\n"; - let actual = state_with_header(content, "Apache-2.0", vec![]); - let plan = plan_file( + let plan = plan( content, - &actual, - &intent("MIT"), + &detected(content), + "MIT", &CommentSyntax::line_only("//"), ChangeMode::Additive, - None, - ); + ) + .unwrap(); let new = plan.new_content.unwrap(); assert!(new.contains("Apache-2.0")); assert!(new.contains("MIT")); assert!(plan.contradiction); } + #[test] + fn additive_block_style_appends_before_closer() { + let content = "/*\n * SPDX-License-Identifier: MIT\n */\ncode\n"; + let plan = plan( + content, + &detected(content), + "Apache-2.0", + &CommentSyntax::both("//", "/*", "*/", " * "), + ChangeMode::Additive, + ) + .unwrap(); + assert_eq!( + plan.new_content.unwrap(), + "/*\n * SPDX-License-Identifier: MIT\n * SPDX-License-Identifier: Apache-2.0\n */\ncode\n" + ); + assert!(plan.contradiction); + } + + #[test] + fn additive_same_line_closer_replays_style_closer() { + let content = "\n\n"; + let plan = plan( + content, + &detected(content), + "Apache-2.0", + &CommentSyntax::block_only("", ""), + ChangeMode::Additive, + ) + .unwrap(); + assert_eq!( + plan.new_content.unwrap(), + "\n\n\n" + ); + } + + #[test] + fn copyright_only_block_gains_license_line() { + let content = "# SPDX-FileCopyrightText: 2026 Acme\n\ncode\n"; + let plan = plan( + content, + &detected(content), + "MIT", + &CommentSyntax::line_only("#"), + ChangeMode::Destructive, + ) + .unwrap(); + assert_eq!( + plan.new_content.unwrap(), + "# SPDX-FileCopyrightText: 2026 Acme\n# SPDX-License-Identifier: MIT\n\ncode\n" + ); + } + #[test] fn missing_header_inserts() { let content = "code\n"; @@ -258,14 +762,14 @@ mod tests { encoding_ok: true, ..Default::default() }; - let plan = plan_file( + let plan = plan( content, &actual, - &intent("MIT"), + "MIT", &CommentSyntax::line_only("//"), ChangeMode::Destructive, - None, - ); + ) + .unwrap(); assert!(plan.wrote_header); assert!( plan.new_content @@ -277,16 +781,109 @@ mod tests { #[test] fn compliant_is_noop() { let content = "// SPDX-License-Identifier: MIT\n\ncode\n"; - let actual = state_with_header(content, "MIT", vec![]); + let plan = plan( + content, + &detected(content), + "MIT", + &CommentSyntax::line_only("//"), + ChangeMode::Destructive, + ) + .unwrap(); + assert!(plan.new_content.is_none()); + } + + #[test] + fn copyright_add_appends_notice_when_license_matches() { + // Copyright intent applies even when the license already matches: a + // missing requested notice is a real change, not a no-op. + let content = "// SPDX-License-Identifier: MIT\n\ncode\n"; let plan = plan_file( content, - &actual, - &intent("MIT"), + &detected(content), + &intent_cpr( + "MIT", + CopyrightPolicy::PreserveAndAdd("2026 Acme".to_string()), + ), &CommentSyntax::line_only("//"), ChangeMode::Destructive, None, + ) + .unwrap(); + assert_eq!( + plan.new_content.unwrap(), + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 Acme\n\ncode\n" ); - assert!(plan.new_content.is_none()); + } + + #[test] + fn copyright_replace_swaps_single_notice() { + let content = + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2025 Acme\n\ncode\n"; + let plan = plan_file( + content, + &detected(content), + &intent_cpr("MIT", CopyrightPolicy::Replace("2026 Acme".to_string())), + &CommentSyntax::line_only("//"), + ChangeMode::Destructive, + None, + ) + .unwrap(); + assert_eq!( + plan.new_content.unwrap(), + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 Acme\n\ncode\n" + ); + } + + #[test] + fn copyright_replace_multi_is_unfixable() { + let content = "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: A\n// SPDX-FileCopyrightText: B\n\ncode\n"; + let err = plan_file( + content, + &detected(content), + &intent_cpr("MIT", CopyrightPolicy::Replace("2026 Acme".to_string())), + &CommentSyntax::line_only("//"), + ChangeMode::Destructive, + None, + ) + .unwrap_err(); + assert_eq!(err.kind, PlanErrorKind::Unfixable); + assert_eq!(err.line, Some(2)); + } + + #[test] + fn license_drift_and_copyright_add_combine() { + let content = "// SPDX-License-Identifier: Apache-2.0\n\ncode\n"; + let plan = plan_file( + content, + &detected(content), + &intent_cpr( + "MIT", + CopyrightPolicy::PreserveAndAdd("2026 Acme".to_string()), + ), + &CommentSyntax::line_only("//"), + ChangeMode::Destructive, + None, + ) + .unwrap(); + assert_eq!( + plan.new_content.unwrap(), + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 Acme\n\ncode\n" + ); + } + + #[test] + fn explicit_target_index_out_of_range_is_invalid() { + let content = "// SPDX-License-Identifier: Apache-2.0\n\ncode\n"; + let err = plan_file( + content, + &detected(content), + &intent("MIT"), + &CommentSyntax::line_only("//"), + ChangeMode::Destructive, + Some(3), + ) + .unwrap_err(); + assert_eq!(err.kind, PlanErrorKind::InvalidTarget); } } // REUSE-IgnoreEnd diff --git a/src/report/classify.rs b/src/report/classify.rs index 23e6b9e..e3c2e23 100644 --- a/src/report/classify.rs +++ b/src/report/classify.rs @@ -5,7 +5,8 @@ use std::path::PathBuf; use crate::detect::candidate_licenses; use crate::domain::{ - ActualLicenseState, DriftClass, FileLicensingState, LicenseIntent, RuleConflict, + ActualLicenseState, CopyrightDrift, DriftClass, FileLicensingState, LicenseIntent, + RuleConflict, combine_licenses, copyright_policy_satisfied, }; use crate::spdx; @@ -31,22 +32,30 @@ pub fn classify(input: ClassifyInput) -> FileLicensingState { excluded, } = input; - let drift = if excluded { - DriftClass::Excluded + let (drift, copyright_drift) = if excluded { + (DriftClass::Excluded, None) } else if !actual.encoding_ok { - DriftClass::Unreadable - } else if conflict.is_some() { - // An unresolved conflict means we cannot determine a single declared intent. - DriftClass::WrongLicense { - declared: "".to_string(), - actual: actual - .detected_license - .clone() - .unwrap_or_else(|| "".to_string()), - } + (DriftClass::Unreadable, None) + } else if let Some(c) = &conflict { + // An unresolved conflict means no single declared intent exists. The + // tied expressions are named descriptively — never a fabricated + // license token — and the conflict itself stays structured in + // `conflict` plus a `rule_conflict` diagnostic. + let tied = c.message.clone(); + let actual_text = actual + .detected_license + .clone() + .unwrap_or_else(|| "".to_string()); + ( + DriftClass::WrongLicense { + declared: tied, + actual: actual_text, + }, + None, + ) } else { match declared { - None => DriftClass::Uncovered, + None => (DriftClass::Uncovered, None), Some(intent) => classify_against(intent, &actual), } }; @@ -58,29 +67,148 @@ pub fn classify(input: ClassifyInput) -> FileLicensingState { actual, drift, conflict, + copyright_drift, } } /// Compare a declared intent against detected actual state. -fn classify_against(intent: &LicenseIntent, actual: &ActualLicenseState) -> DriftClass { +/// +/// License equality uses **all** effective expressions joined as one `AND` +/// expression, canonicalized once: a matching MIT candidate never hides an +/// additional Apache license (data-model §6). Copyright is compared +/// separately against the policy; when the license matches but copyright does +/// not the drift is `CopyrightMismatch`, and when both differ the copyright +/// mismatch is retained as a diagnostic next to `WrongLicense`. +fn classify_against( + intent: &LicenseIntent, + actual: &ActualLicenseState, +) -> (DriftClass, Option) { let candidates = candidate_licenses(actual); if candidates.is_empty() { - return DriftClass::MissingHeader; + return (DriftClass::MissingHeader, None); } - // Compliant if any detected license matches the declared expression semantically. - let matches = candidates - .iter() - .any(|c| spdx::expressions_equal(c, &intent.license_expression)); - if matches { - DriftClass::Compliant + let combined = combine_licenses(&candidates); + let license_matches = combined + .as_deref() + .is_some_and(|c| spdx::expressions_equal(c, &intent.license_expression)); + let copyright_ok = + copyright_policy_satisfied(&intent.copyright_policy, &actual.detected_copyrights); + let copyright_drift = if copyright_ok { + None } else { - DriftClass::WrongLicense { + Some(CopyrightDrift::new( + &intent.copyright_policy, + &actual.detected_copyrights, + )) + }; + if !license_matches { + let drift = DriftClass::WrongLicense { declared: intent.license_expression.clone(), - actual: actual - .detected_license - .clone() - .unwrap_or_else(|| candidates.join(", ")), - } + actual: combined.unwrap_or_else(|| candidates.join(", ")), + }; + return (drift, copyright_drift); + } + if let Some(cd) = copyright_drift { + let drift = DriftClass::CopyrightMismatch { + declared: cd.declared.clone(), + actual: cd.actual.clone(), + }; + return (drift, Some(cd)); + } + (DriftClass::Compliant, None) +} + +/// One REUSE-validation diagnostic for a file: a stable machine-readable code +/// plus a human message. Codes are never fabricated license strings. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReuseDiagnostic { + pub code: &'static str, + pub message: String, +} + +/// Outcome of validating one file's actual licensing against REUSE 3.3, +/// independently of any declared policy (`lint`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReuseEval { + /// False on any violation or incomplete validation. + pub passed: bool, + /// True when validation could not be completed (unreadable bytes or an + /// unread dependency): never a proven violation and never a pass. + pub incomplete: bool, + pub diagnostics: Vec, +} + +/// Validate actual file state against REUSE 3.3: every covered file needs a +/// license expression and a copyright notice, and every license value must be +/// well-formed. `excluded` files are not covered and always pass. +/// `read_error` carries the snapshot read failure when the file could not be +/// read at all (an unread dependency → incomplete, not a violation). +pub fn evaluate_reuse( + excluded: bool, + actual: &ActualLicenseState, + read_error: Option<&str>, +) -> ReuseEval { + if excluded { + return ReuseEval { + passed: true, + incomplete: false, + diagnostics: Vec::new(), + }; + } + let mut diagnostics = Vec::new(); + let mut incomplete = false; + if let Some(reason) = read_error { + incomplete = true; + diagnostics.push(ReuseDiagnostic { + code: "read_error", + message: format!("cannot validate file: {reason}"), + }); + } else if !actual.encoding_ok { + incomplete = true; + diagnostics.push(ReuseDiagnostic { + code: "unsupported_encoding", + message: "file is not valid UTF-8 and has no out-of-band coverage; \ + licensing cannot be determined" + .to_string(), + }); + } + if !actual.invalid_license_values.is_empty() { + let details = actual + .invalid_license_values + .iter() + .map(|v| format!("line {}: `{}` ({})", v.line, v.value, v.reason)) + .collect::>() + .join("; "); + diagnostics.push(ReuseDiagnostic { + code: "invalid_license", + message: format!("invalid SPDX-License-Identifier value: {details}"), + }); + } + // The reference tool flattens snippet notices into the file's info, so + // REUSE validation counts snippet licenses and copyrights — while declared + // policy never does (decision 5: snippets never satisfy file policy). + if candidate_licenses(actual).is_empty() && actual.snippet_licenses.is_empty() { + diagnostics.push(ReuseDiagnostic { + code: "missing_license", + message: "no license expression covers this file".to_string(), + }); + } + let has_copyright = actual + .detected_copyrights + .iter() + .chain(actual.snippet_copyrights.iter()) + .any(|c| !c.trim().is_empty()); + if !has_copyright { + diagnostics.push(ReuseDiagnostic { + code: "missing_copyright", + message: "no copyright notice covers this file".to_string(), + }); + } + let passed = diagnostics.is_empty() && !incomplete; + ReuseEval { + passed, + incomplete, + diagnostics, } } @@ -96,23 +224,31 @@ mod tests { } } - fn header_state(license: &str) -> ActualLicenseState { + fn header_state_full(licenses: &[&str], copyrights: &[&str]) -> ActualLicenseState { ActualLicenseState { headers: vec![HeaderBlock { byte_range: (0, 10), - license_ids: vec![license.to_string()], - copyrights: vec![], + license_ids: licenses.iter().map(|s| s.to_string()).collect(), + license_spans: licenses.iter().map(|_| (0, 0)).collect(), + copyrights: copyrights.iter().map(|s| s.to_string()).collect(), + copyright_spans: copyrights.iter().map(|_| (0, 0)).collect(), position_after: PositionAfter::FileStart, }], out_of_band: None, - detected_license: Some(license.to_string()), + detected_license: licenses.first().map(|s| s.to_string()), detected_source: Some(ActualSource::Header), - detected_copyrights: vec![], + detected_copyrights: copyrights.iter().map(|s| s.to_string()).collect(), snippet_licenses: vec![], + snippet_copyrights: vec![], encoding_ok: true, + invalid_license_values: vec![], } } + fn header_state(license: &str) -> ActualLicenseState { + header_state_full(&[license], &[]) + } + fn input<'a>( declared: Option<&'a LicenseIntent>, actual: ActualLicenseState, @@ -185,4 +321,134 @@ mod tests { )); assert_eq!(s.drift, DriftClass::Unreadable); } + + fn intent_with_copyright(license: &str, copyright: &str) -> LicenseIntent { + LicenseIntent { + license_expression: license.to_string(), + copyright_policy: CopyrightPolicy::PreserveAndAdd(copyright.to_string()), + } + } + + #[test] + fn copyright_add_requires_notice_plus_existing() { + // License matches; the requested notice is present alongside another. + let s = classify(input( + Some(&intent_with_copyright("MIT", "2026 Acme")), + header_state_full(&["MIT"], &["2024 Old", "2026 Acme"]), + )); + assert_eq!(s.drift, DriftClass::Compliant); + assert!(s.copyright_drift.is_none()); + } + + #[test] + fn copyright_add_missing_notice_is_mismatch() { + let s = classify(input( + Some(&intent_with_copyright("MIT", "2026 Acme")), + header_state_full(&["MIT"], &["2024 Someone"]), + )); + assert!( + matches!(s.drift, DriftClass::CopyrightMismatch { .. }), + "got {:?}", + s.drift + ); + let cd = s.copyright_drift.expect("diagnostic retained"); + assert_eq!(cd.declared, "add:2026 Acme"); + } + + #[test] + fn copyright_add_without_any_notice_is_mismatch() { + // `add` requires existing notices, not just the requested one: a bare + // file does not satisfy it. + let s = classify(input( + Some(&intent_with_copyright("MIT", "2026 Acme")), + header_state_full(&["MIT"], &[]), + )); + assert!(matches!(s.drift, DriftClass::CopyrightMismatch { .. })); + } + + #[test] + fn license_and_copyright_both_differ_keep_diagnostic() { + let s = classify(input( + Some(&intent_with_copyright("MIT", "2026 Acme")), + header_state_full(&["Apache-2.0"], &["2024 Someone"]), + )); + assert!(matches!(s.drift, DriftClass::WrongLicense { .. })); + assert!( + s.copyright_drift.is_some(), + "copyright mismatch retained as diagnostic" + ); + } + + #[test] + fn copyright_replace_needs_exact_notice() { + let target = LicenseIntent { + license_expression: "MIT".to_string(), + copyright_policy: CopyrightPolicy::Replace("2026 Acme".to_string()), + }; + let exact = classify(input( + Some(&target), + header_state_full(&["MIT"], &["2026 Acme"]), + )); + assert_eq!(exact.drift, DriftClass::Compliant); + let extra = classify(input( + Some(&target), + header_state_full(&["MIT"], &["2026 Acme", "2024 Old"]), + )); + assert!(matches!(extra.drift, DriftClass::CopyrightMismatch { .. })); + } + + #[test] + fn multi_license_combination_must_match() { + // One matching candidate never hides an additional license. + let single = intent("MIT"); + let s = classify(input( + Some(&single), + header_state_full(&["MIT", "Apache-2.0"], &[]), + )); + assert!(matches!(s.drift, DriftClass::WrongLicense { .. })); + let combo = intent("MIT AND Apache-2.0"); + let s = classify(input( + Some(&combo), + header_state_full(&["MIT", "Apache-2.0"], &[]), + )); + assert_eq!(s.drift, DriftClass::Compliant); + } + + #[test] + fn reuse_eval_needs_license_and_copyright() { + let bare = ActualLicenseState { + encoding_ok: true, + ..Default::default() + }; + let eval = evaluate_reuse(false, &bare, None); + assert!(!eval.passed && !eval.incomplete); + let codes: Vec<_> = eval.diagnostics.iter().map(|d| d.code).collect(); + assert!(codes.contains(&"missing_license")); + assert!(codes.contains(&"missing_copyright")); + // Excluded files are not covered: nothing required. + let eval = evaluate_reuse(true, &bare, None); + assert!(eval.passed); + } + + #[test] + fn reuse_eval_unreadable_is_incomplete() { + let bad = ActualLicenseState { + encoding_ok: false, + ..Default::default() + }; + let eval = evaluate_reuse(false, &bad, None); + assert!(!eval.passed && eval.incomplete); + assert!( + eval.diagnostics + .iter() + .any(|d| d.code == "unsupported_encoding") + ); + let good = ActualLicenseState { + encoding_ok: true, + ..Default::default() + }; + let eval = evaluate_reuse(false, &good, Some("snapshot read failed")); + assert!(!eval.passed && eval.incomplete); + assert!(eval.diagnostics.iter().any(|d| d.code == "read_error")); + } } diff --git a/src/report/mod.rs b/src/report/mod.rs index 1c8f27a..ea08ad0 100644 --- a/src/report/mod.rs +++ b/src/report/mod.rs @@ -8,17 +8,24 @@ use serde::Serialize; use crate::domain::{DriftClass, FileChange, FileLicensingState}; -/// Top-level machine-readable report (`--format json`). +/// Top-level machine-readable report (`--format json`, contract v2). #[derive(Debug, Serialize)] pub struct Report { pub version: u8, pub command: String, #[serde(skip_serializing_if = "Option::is_none")] pub exit_code: Option, + /// How the evaluated content was sourced (`worktree`, `index`). + pub snapshot: String, pub summary: Summary, pub files: Vec, + /// Structured diagnostics, sorted by path then code (v2 name for warnings). #[serde(skip_serializing_if = "Vec::is_empty")] - pub warnings: Vec, + pub diagnostics: Vec, + /// Independent write records: the same plan/execution log, never joined + /// to asset states (a metadata patch covers many files). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub writes: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub license_texts: Option, } @@ -28,6 +35,18 @@ pub struct Summary { pub pass: bool, #[serde(skip_serializing_if = "Option::is_none")] pub partial: Option, + /// Final verification ran to completion (false when the post-write + /// re-scan itself failed and the report rests on planned data). + pub complete: bool, + /// Gate state before apply ran (apply only; dry-run reports the same + /// pre-apply gate, since nothing was written). + #[serde(skip_serializing_if = "Option::is_none")] + pub before_pass: Option, + /// Dry-run only: whether executing the planned writes is projected to + /// pass the gate. `summary.pass` mirrors it, labeled as projected in + /// human output. + #[serde(skip_serializing_if = "Option::is_none")] + pub projected_pass: Option, pub counts: Counts, } @@ -35,6 +54,7 @@ pub struct Summary { pub struct Counts { pub compliant: u32, pub wrong_license: u32, + pub copyright_mismatch: u32, pub missing_header: u32, pub uncovered: u32, pub excluded: u32, @@ -59,6 +79,20 @@ pub struct FileEntry { pub conflict: Option, #[serde(skip_serializing_if = "Option::is_none")] pub change: Option, + /// Contributing out-of-band tables, shallowest document first (omitted + /// when the file has no OOB coverage). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub metadata_origins: Vec, +} + +/// Provenance of one metadata table behind a file's effective licensing. +#[derive(Debug, Serialize)] +pub struct MetadataOriginEntry { + pub metadata: String, + pub table: usize, + pub precedence: String, + pub licenses: Vec, + pub copyrights: Vec, } #[derive(Debug, Serialize)] @@ -77,14 +111,72 @@ pub struct ChangeEntry { pub applied: bool, } +/// One structured diagnostic: a stable machine-readable code plus a human +/// message (report contract v2; formerly `warnings`/`kind`). #[derive(Debug, Clone, Serialize)] -pub struct Warning { - pub kind: String, +pub struct Diagnostic { + pub code: String, #[serde(skip_serializing_if = "Option::is_none")] pub path: Option, pub message: String, } +/// One write record for the report: destination, kind, outcome, the selected +/// files it covers, and — for text being written — the exact bytes as text. +/// Byte buffers stay in [`crate::domain::ExecutedWrite`]; only text crosses +/// into JSON, never binary. +#[derive(Debug, Clone, Serialize)] +pub struct WriteEntry { + pub path: String, + pub kind: String, + pub status: String, + pub affected_files: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub before_text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub after_text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +impl WriteEntry { + pub fn from_executed(exec: &crate::domain::ExecutedWrite) -> Self { + let text = |bytes: Option<&Vec>| bytes.map(|b| String::from_utf8_lossy(b).into_owned()); + WriteEntry { + path: exec.write.path.to_string_lossy().replace('\\', "/"), + kind: exec.write.kind.as_str().to_string(), + status: exec.status.as_str().to_string(), + affected_files: exec + .write + .affected_files + .iter() + .map(|p| p.to_string_lossy().replace('\\', "/")) + .collect(), + before_text: text(exec.write.before.as_ref()), + after_text: text(exec.write.after.as_ref()), + message: exec.message.clone(), + } + } +} + +/// Extra report inputs beyond states/changes/diagnostics. +#[derive(Debug, Default)] +pub struct ReportMeta { + /// Content source label (`worktree`, `index`). + pub snapshot: String, + /// Independent write records (apply: plan dry-run, outcomes real run). + pub writes: Vec, + /// Dry-run only: projected gate outcome. + pub projected_pass: Option, + /// Apply only: gate state before apply ran. + pub before_pass: Option, + /// False when final verification did not complete. + pub complete: bool, + /// Violations beyond state drift (blocked license texts): force the gate + /// shut even when every file state is compliant. + pub extra_violations: bool, +} + #[derive(Debug, Default, Serialize)] pub struct LicenseTexts { pub referenced: Vec, @@ -92,42 +184,69 @@ pub struct LicenseTexts { pub missing: Vec, pub bundled_available: Vec, pub spdx_list_version: String, + /// Present but never referenced (project-wide lint finding only). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub unused: Vec, + /// `LICENSES/` entries naming no recognizable license (lint finding). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub unrecognized: Vec, + /// Recognized ids kept in extensionless files (lint finding). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub missing_extension: Vec, +} + +/// Drift counts for one classified state set. +pub fn count_drift(states: &[FileLicensingState], diagnostics: &[Diagnostic]) -> Counts { + let mut counts = Counts::default(); + for s in states { + match &s.drift { + DriftClass::Compliant => counts.compliant += 1, + DriftClass::WrongLicense { .. } => counts.wrong_license += 1, + DriftClass::CopyrightMismatch { .. } => counts.copyright_mismatch += 1, + DriftClass::MissingHeader => counts.missing_header += 1, + DriftClass::Uncovered => counts.uncovered += 1, + DriftClass::Excluded => counts.excluded += 1, + DriftClass::Unreadable => counts.unreadable += 1, + } + if s.conflict.is_some() { + counts.conflicts += 1; + } + } + for d in diagnostics { + if d.code == "contradiction" { + counts.contradictions += 1; + } + } + counts +} + +/// Gate predicate: true only when no drift, conflict, or unreadable file fails +/// the gate. The one formula behind both `summary.pass` and the process exit. +pub fn counts_pass(counts: &Counts) -> bool { + counts.wrong_license == 0 + && counts.copyright_mismatch == 0 + && counts.missing_header == 0 + && counts.uncovered == 0 + && counts.unreadable == 0 + && counts.conflicts == 0 } impl Report { - /// Assemble a report from classified file states plus any changes/warnings. + /// Assemble a report from classified file states plus any changes/diagnostics. pub fn build( command: &str, states: &[FileLicensingState], changes: &[FileChange], - warnings: Vec, + diagnostics: Vec, partial: Option, + meta: ReportMeta, ) -> Self { - let mut counts = Counts::default(); - for s in states { - match &s.drift { - DriftClass::Compliant => counts.compliant += 1, - DriftClass::WrongLicense { .. } => counts.wrong_license += 1, - DriftClass::MissingHeader => counts.missing_header += 1, - DriftClass::Uncovered => counts.uncovered += 1, - DriftClass::Excluded => counts.excluded += 1, - DriftClass::Unreadable => counts.unreadable += 1, - } - if s.conflict.is_some() { - counts.conflicts += 1; - } - } - for w in &warnings { - if w.kind == "contradiction" { - counts.contradictions += 1; - } - } - - let pass = counts.wrong_license == 0 - && counts.missing_header == 0 - && counts.uncovered == 0 - && counts.unreadable == 0 - && counts.conflicts == 0; + let counts = count_drift(states, &diagnostics); + // Dry-run pass means projected success; every other command reports + // the evaluated gate, including violations beyond state drift. + let pass = meta + .projected_pass + .unwrap_or_else(|| counts_pass(&counts) && !meta.extra_violations); let change_by_path: std::collections::HashMap<&std::path::Path, &FileChange> = changes.iter().map(|c| (c.path.as_path(), c)).collect(); @@ -137,17 +256,27 @@ impl Report { .map(|s| file_entry(s, change_by_path.get(s.path.as_path()).copied())) .collect(); + let mut diagnostics = diagnostics; + diagnostics.sort_by(|a, b| { + (a.path.clone(), a.code.clone()).cmp(&(b.path.clone(), b.code.clone())) + }); + Report { - version: 1, + version: 2, command: command.to_string(), exit_code: None, + snapshot: meta.snapshot, summary: Summary { pass, partial, + complete: meta.complete, + before_pass: meta.before_pass, + projected_pass: meta.projected_pass, counts, }, files, - warnings, + diagnostics, + writes: meta.writes.iter().map(WriteEntry::from_executed).collect(), license_texts: None, } } @@ -160,7 +289,8 @@ impl Report { fn file_entry(s: &FileLicensingState, change: Option<&FileChange>) -> FileEntry { let (declared, actual) = match &s.drift { - DriftClass::WrongLicense { declared, actual } => { + DriftClass::WrongLicense { declared, actual } + | DriftClass::CopyrightMismatch { declared, actual } => { (Some(declared.clone()), Some(actual.clone())) } _ => ( @@ -177,6 +307,23 @@ fn file_entry(s: &FileLicensingState, change: Option<&FileChange>) -> FileEntry actual, actual_source: s.actual.detected_source.map(|x| x.as_str().to_string()), matched_rule: s.matched_rule.clone(), + metadata_origins: s + .actual + .out_of_band + .as_ref() + .map(|o| { + o.origins + .iter() + .map(|origin| MetadataOriginEntry { + metadata: origin.metadata_path.to_string_lossy().replace('\\', "/"), + table: origin.table_index, + precedence: origin.precedence.as_str().to_string(), + licenses: origin.licenses.clone(), + copyrights: origin.copyrights.clone(), + }) + .collect() + }) + .unwrap_or_default(), conflict: s.conflict.as_ref().map(|c| ConflictEntry { rules: c.rules.clone(), message: c.message.clone(), diff --git a/src/report/render.rs b/src/report/render.rs index 7d2d0ff..16652cd 100644 --- a/src/report/render.rs +++ b/src/report/render.rs @@ -37,15 +37,52 @@ pub fn render_human(report: &Report) -> String { } } - if !report.warnings.is_empty() { + if !report.writes.is_empty() { + out.push_str("\nWrites:\n"); + for w in &report.writes { + out.push_str(&format!(" [{}] {} ({})\n", w.status, w.path, w.kind)); + if (w.status == "failed" || w.status == "blocked") + && let Some(m) = &w.message + { + out.push_str(&format!(" {m}\n")); + } + if !w.affected_files.is_empty() && w.affected_files.len() > 1 { + out.push_str(&format!(" covers {} files\n", w.affected_files.len())); + } + // Planned writes preview exact bytes (dry-run); applied writes + // stay compact — the changed path above is the record. + if w.status == "planned" { + match &w.before_text { + Some(before) => { + out.push_str(&format!(" --- before: {}\n", w.path)); + for line in before.lines() { + out.push_str(&format!(" {line}\n")); + } + } + None => out.push_str(" --- before: (new file)\n"), + } + match &w.after_text { + Some(after) => { + out.push_str(&format!(" --- after: {}\n", w.path)); + for line in after.lines() { + out.push_str(&format!(" {line}\n")); + } + } + None => out.push_str(" --- after: (determined at execution)\n"), + } + } + } + } + + if !report.diagnostics.is_empty() { out.push_str("\nWarnings:\n"); - for w in &report.warnings { + for w in &report.diagnostics { let path = w .path .as_ref() .map(|p| format!("{p}: ")) .unwrap_or_default(); - out.push_str(&format!(" [{}] {}{}\n", w.kind, path, w.message)); + out.push_str(&format!(" [{}] {}{}\n", w.code, path, w.message)); } } @@ -68,20 +105,74 @@ pub fn render_human(report: &Report) -> String { out } -/// Render a single `--explain` line for a path. -pub fn render_explain(path: &str, drift: &DriftClass, matched_rule: &Option) -> String { - match matched_rule { - Some(rule) => format!( - "{path}: winning rule `{rule}` → {} ({})", - describe(drift), - drift.as_str() - ), - None => format!( - "{path}: no rule matched; {} ({})", - describe(drift), - drift.as_str() - ), +/// Input for a single-path `--explain` rendering (FR-022): the winning rule +/// (or default), the rules that matched but lost, exclusions, metadata +/// provenance, and the current drift — all resolved directly, without a +/// whole-tree scan. +pub struct ExplainInput<'a> { + pub path: &'a str, + pub drift: &'a DriftClass, + /// 1-based rule number, selector label, and winning intent. + pub winner: Option<(usize, String, String)>, + /// Repo-wide default intent, when no rule won. + pub default_intent: Option, + /// Equal-specificity rules tied with the winner (FR-022 conflict). + pub conflict_rules: Vec<(usize, String)>, + /// Matching rules that lost: 1-based number, selector label, specificity. + pub losers: Vec<(usize, String, u32)>, + pub excluded_by_config: bool, + pub reuse_ignored: bool, + /// Where the actual license came from, shallowest detail last. + pub sources: Vec, + pub snapshot: &'a str, +} + +/// Render a `--explain` block for one path. +pub fn render_explain(input: &ExplainInput) -> String { + let mut out = format!( + "{}: {} ({})\n", + input.path, + describe(input.drift), + input.drift.as_str() + ); + if let Some((n, label, intent)) = &input.winner { + out.push_str(&format!( + " winning rule #{n} `{label}`: intent `{intent}`\n" + )); + } else if let Some(intent) = &input.default_intent { + out.push_str(&format!(" no rule matched; default intent `{intent}`\n")); + } else { + out.push_str(" no rule matched and no default is set\n"); + } + for (n, label) in &input.conflict_rules { + out.push_str(&format!( + " tied rule #{n} `{label}` (equal specificity, differing intent)\n" + )); + } + for (n, label, spec) in &input.losers { + out.push_str(&format!( + " losing rule #{n} `{label}` (specificity {spec})\n" + )); } + if input.excluded_by_config { + out.push_str(" excluded by a declaration `[exclude]` pattern\n"); + } + if input.reuse_ignored { + out.push_str(" REUSE-ignored (license text, sidecar, metadata, or VCS path)\n"); + } + if !input.excluded_by_config && !input.reuse_ignored { + out.push_str(" exclusions: none\n"); + } + if input.sources.is_empty() { + out.push_str(" metadata sources: none observed\n"); + } else { + out.push_str(" metadata sources:\n"); + for s in &input.sources { + out.push_str(&format!(" - {s}\n")); + } + } + out.push_str(&format!(" snapshot: {}\n", input.snapshot)); + out } fn describe(drift: &DriftClass) -> String { @@ -90,6 +181,9 @@ fn describe(drift: &DriftClass) -> String { DriftClass::WrongLicense { declared, actual } => { format!("declared `{declared}` but found `{actual}`") } + DriftClass::CopyrightMismatch { declared, actual } => { + format!("copyright policy requires `{declared}` but found {actual}") + } DriftClass::MissingHeader => "covered but no header present".to_string(), DriftClass::Uncovered => "no rule or default covers this path".to_string(), DriftClass::Excluded => "explicitly excluded".to_string(), diff --git a/src/reuse/atomic.rs b/src/reuse/atomic.rs new file mode 100644 index 0000000..87b52f4 --- /dev/null +++ b/src/reuse/atomic.rs @@ -0,0 +1,684 @@ +//! Byte-safe atomic file replacement confined to an allowed root (F01, FR-024). +//! +//! Every mutation in the tool (source headers, sidecars, `REUSE.toml`, generated +//! configs, license texts) goes through [`atomic_write`], which validates the +//! destination **inside** the helper so no caller can forget a check: +//! +//! - the relative path cannot escape the allowed root (no absolute paths, no `..`), +//! - no ancestor (and never the destination itself) may be a symlink / reparse point, +//! - the destination must be a regular file (directories, FIFOs, sockets rejected), +//! - the caller states what it expects to find (`None` = must not exist, `Some` = +//! exact bytes); anything else aborts before touching the destination, +//! - replacement uses an exclusively-created sibling temp file +//! ([`tempfile::NamedTempFile`]) — never a predictable name — preserves the +//! existing file's permissions, fsyncs content, re-verifies the destination, +//! then renames; directory durability is synced where supported. +//! +//! The helper is portable containment, not an OS-level compare-and-swap against a +//! hostile process concurrently replacing ancestors: it detects changes observed +//! between planning and replacement but makes no stronger race guarantee. + +use std::io::{self, Write}; +use std::path::{Component, Path, PathBuf}; +use std::sync::Mutex; + +/// Failure of [`atomic_write`]. +#[derive(Debug, thiserror::Error)] +#[error("{source}")] +pub struct WriteError { + /// The underlying I/O failure. + pub source: std::io::Error, + /// True when the replacement bytes were committed (rename succeeded) and the + /// failure happened afterward (durability sync). Callers must count such a + /// write as applied even though an error is reported. + pub replacement_completed: bool, +} + +impl WriteError { + fn before(source: io::Error) -> Self { + WriteError { + source, + replacement_completed: false, + } + } + + fn after(source: io::Error) -> Self { + WriteError { + source, + replacement_completed: true, + } + } + + /// A destination read failure (permission, I/O) surfaced as a write error + /// that never committed anything. + pub fn for_read_failure(source: io::Error) -> Self { + WriteError::before(source) + } +} + +impl From for io::Error { + fn from(w: WriteError) -> io::Error { + w.source + } +} + +/// Library-level seam forcing the next [`atomic_write`] to a given (canonical) +/// destination to fail its post-persist durability sync — after the replacement +/// bytes are committed. Path-scoped (and consumed one-shot) so parallel tests +/// using other destinations are unaffected. Hidden test hook, not a CLI switch. +static POST_PERSIST_FAILURE_PATH: Mutex> = Mutex::new(None); + +/// Arm the post-persist sync failure for one write to `canonical_dest`. +/// Pass the canonical destination path (e.g. `dir.canonicalize()?.join(rel)`). +#[doc(hidden)] +pub fn __licet_test_force_post_persist_sync_failure_for(canonical_dest: &Path) { + *POST_PERSIST_FAILURE_PATH.lock().unwrap() = Some(canonical_dest.to_path_buf()); +} + +/// Take the armed failure iff it targets `dest`. +fn take_post_persist_failure_for(dest: &Path) -> bool { + let mut guard = POST_PERSIST_FAILURE_PATH.lock().unwrap(); + if guard.as_deref() == Some(dest) { + *guard = None; + true + } else { + false + } +} + +/// Read a mutation destination, distinguishing absence from failure (F13). +/// +/// `Ok(None)` only on `NotFound`; permission errors, I/O failures, and (via the +/// caller) invalid UTF-8 are errors, never silent absence. +pub fn read_expected_for_write(path: &Path) -> io::Result>> { + match std::fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } +} + +/// Atomically replace (or create) `relative` under `root`. +/// +/// - `expected = None`: the destination must not exist (creation). +/// - `expected = Some(bytes)`: the destination must be a regular file whose +/// current bytes equal `bytes`; anything else aborts with no write. +/// - `replacement`: exact bytes to install. +/// +/// See the module docs for the containment checks applied to every call. +pub fn atomic_write( + root: &Path, + relative: &Path, + expected: Option<&[u8]>, + replacement: &[u8], +) -> Result<(), WriteError> { + let rel = join_checked(relative)?; + let canon_root = root.canonicalize().map_err(|e| { + WriteError::before(io::Error::new( + e.kind(), + format!("cannot resolve allowed root {}: {e}", root.display()), + )) + })?; + let dest = canon_root.join(&rel); + let parent = dest.parent().ok_or_else(|| { + WriteError::before(io::Error::new( + io::ErrorKind::InvalidInput, + format!("destination has no parent: {}", relative.display()), + )) + })?; + + check_ancestors(&canon_root, parent, relative)?; + + // Classify the destination without following symlinks; permission/read + // failures are errors, never absence (F13). + enum Dest { + Absent, + Present { permissions: std::fs::Permissions }, + } + let dest_state = match std::fs::symlink_metadata(&dest) { + Err(e) if e.kind() == io::ErrorKind::NotFound => { + if expected.is_some() { + return Err(WriteError::before(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "destination {} disappeared or was never read; refusing to write", + relative.display() + ), + ))); + } + Dest::Absent + } + Err(e) => { + return Err(WriteError::before(io::Error::new( + e.kind(), + format!("cannot stat destination {}: {e}", relative.display()), + ))); + } + Ok(meta) => { + let ft = meta.file_type(); + if is_link(&meta) { + return Err(WriteError::before(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "refusing to write through symlink destination {}", + relative.display() + ), + ))); + } + if !ft.is_file() { + return Err(WriteError::before(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "refusing to replace non-regular destination {}", + relative.display() + ), + ))); + } + match expected { + None => { + return Err(WriteError::before(io::Error::new( + io::ErrorKind::AlreadyExists, + format!( + "destination {} already exists; refusing to create", + relative.display() + ), + ))); + } + Some(exp) => { + let current = std::fs::read(&dest).map_err(|e| { + WriteError::before(io::Error::new( + e.kind(), + format!("cannot re-read destination {}: {e}", relative.display()), + )) + })?; + if current.as_slice() != exp { + return Err(WriteError::before(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "destination {} changed since it was read; refusing to replace", + relative.display() + ), + ))); + } + Dest::Present { + permissions: meta.permissions(), + } + } + } + } + }; + + // Exclusively-created temp file in the destination directory: random name, so + // a pre-existing predictable `.licet.tmp` (regular file or symlink) is + // never touched. + let mut tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| { + WriteError::before(io::Error::new( + e.kind(), + format!("cannot create temp file for {}: {e}", relative.display()), + )) + })?; + if let Err(e) = (|| { + tmp.write_all(replacement)?; + tmp.flush()?; + match &dest_state { + Dest::Present { permissions } => { + tmp.as_file().set_permissions(permissions.clone())?; + } + #[cfg(unix)] + Dest::Absent => { + use std::os::unix::fs::PermissionsExt; + tmp.as_file() + .set_permissions(std::fs::Permissions::from_mode(0o644))?; + } + #[cfg(not(unix))] + Dest::Absent => {} + } + tmp.as_file().sync_all()?; + Ok::<(), io::Error>(()) + })() { + return Err(WriteError::before(io::Error::new( + e.kind(), + format!("cannot stage replacement for {}: {e}", relative.display()), + ))); + } + + // Re-verify the destination immediately before replacing it. + recheck(&dest, relative, expected)?; + + // Commit. Creation uses no-clobber persistence so a concurrently created file + // is an error, not a silent overwrite. + let persisted = match &dest_state { + Dest::Absent => tmp.persist_noclobber(&dest), + Dest::Present { .. } => tmp.persist(&dest), + } + .map_err(|e| { + WriteError::before(io::Error::new( + e.error.kind(), + format!("cannot replace {}: {}", relative.display(), e.error), + )) + })?; + + // Post-commit durability. Any failure here still leaves the replacement bytes + // on disk, so it is reported with `replacement_completed = true`. + if take_post_persist_failure_for(&dest) { + return Err(WriteError::after(io::Error::other(format!( + "injected post-persist sync failure for {}", + relative.display() + )))); + } + if let Err(e) = persisted.sync_all() { + return Err(WriteError::after(io::Error::new( + e.kind(), + format!( + "replacement of {} committed but sync failed: {e}", + relative.display() + ), + ))); + } + if let Err(e) = sync_dir(parent) { + return Err(WriteError::after(io::Error::new( + e.kind(), + format!( + "replacement of {} committed but directory sync failed: {e}", + relative.display() + ), + ))); + } + Ok(()) +} + +/// Validate `relative` and join it onto an already-canonical root. +/// +/// Rejects absolute paths and any `..` component; `.` components are skipped +/// (they cannot escape the root). +fn join_checked(relative: &Path) -> Result { + if relative.as_os_str().is_empty() { + return Err(WriteError::before(io::Error::new( + io::ErrorKind::InvalidInput, + "destination path is empty", + ))); + } + let mut rel = PathBuf::new(); + let mut saw_normal = false; + for comp in relative.components() { + match comp { + Component::Normal(c) => { + rel.push(c); + saw_normal = true; + } + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(WriteError::before(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "destination {} escapes its allowed root", + relative.display() + ), + ))); + } + } + } + if !saw_normal { + return Err(WriteError::before(io::Error::new( + io::ErrorKind::InvalidInput, + format!("destination {} names no file", relative.display()), + ))); + } + Ok(rel) +} + +/// Reject symlink/reparse ancestors and non-directory ancestors from `parent` +/// up to (and including) the canonical root. +fn check_ancestors(canon_root: &Path, parent: &Path, relative: &Path) -> Result<(), WriteError> { + let mut anc = parent; + loop { + match std::fs::symlink_metadata(anc) { + Ok(meta) => { + if is_link(&meta) { + return Err(WriteError::before(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "refusing to write through symlink ancestor {} for {}", + anc.display(), + relative.display() + ), + ))); + } + if !meta.file_type().is_dir() { + return Err(WriteError::before(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "ancestor {} of {} is not a directory", + anc.display(), + relative.display() + ), + ))); + } + } + Err(e) => { + return Err(WriteError::before(io::Error::new( + e.kind(), + format!( + "cannot stat ancestor {} of {}: {e}", + anc.display(), + relative.display() + ), + ))); + } + } + if anc == canon_root { + break; + } + anc = anc.parent().ok_or_else(|| { + WriteError::before(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "destination {} escapes its allowed root", + relative.display() + ), + )) + })?; + } + Ok(()) +} + +/// Re-verify destination type/content immediately before the rename. +fn recheck(dest: &Path, relative: &Path, expected: Option<&[u8]>) -> Result<(), WriteError> { + match std::fs::symlink_metadata(dest) { + Err(e) if e.kind() == io::ErrorKind::NotFound => { + if expected.is_some() { + return Err(WriteError::before(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "destination {} changed since it was read; refusing to replace", + relative.display() + ), + ))); + } + Ok(()) + } + Err(e) => Err(WriteError::before(io::Error::new( + e.kind(), + format!("cannot re-stat destination {}: {e}", relative.display()), + ))), + Ok(meta) => { + if is_link(&meta) || !meta.file_type().is_file() { + return Err(WriteError::before(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "destination {} changed type since it was read; refusing to replace", + relative.display() + ), + ))); + } + match expected { + None => Err(WriteError::before(io::Error::new( + io::ErrorKind::AlreadyExists, + format!( + "destination {} was created concurrently; refusing to overwrite", + relative.display() + ), + ))), + Some(exp) => { + let current = std::fs::read(dest).map_err(|e| { + WriteError::before(io::Error::new( + e.kind(), + format!("cannot re-read destination {}: {e}", relative.display()), + )) + })?; + if current.as_slice() != exp { + return Err(WriteError::before(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "destination {} changed since it was read; refusing to replace", + relative.display() + ), + ))); + } + Ok(()) + } + } + } + } +} + +/// True for symlinks and (on Windows) any reparse point such as junctions. +fn is_link(meta: &std::fs::Metadata) -> bool { + if meta.file_type().is_symlink() { + return true; + } + #[cfg(windows)] + { + use std::os::windows::fs::FileAttributesExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + } + #[cfg(not(windows))] + { + false + } +} + +/// Fsync a directory handle where supported; no-op elsewhere. +fn sync_dir(dir: &Path) -> io::Result<()> { + #[cfg(unix)] + { + let f = std::fs::File::open(dir)?; + f.sync_all()?; + } + #[cfg(not(unix))] + { + let _ = dir; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn root() -> tempfile::TempDir { + tempfile::tempdir().unwrap() + } + + #[test] + fn replaces_content_and_leaves_no_temp() { + let dir = root(); + let rel = Path::new("f.txt"); + std::fs::write(dir.path().join(rel), "old").unwrap(); + atomic_write(dir.path(), rel, Some(b"old"), b"new content").unwrap(); + assert_eq!(std::fs::read(dir.path().join(rel)).unwrap(), b"new content"); + let leftovers: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .flatten() + .filter(|e| { + e.file_name().to_string_lossy().contains(".licet.tmp") + || e.file_name().to_string_lossy().starts_with(".tmp") + }) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked: {leftovers:?}"); + } + + #[test] + fn creates_missing_destination_only_when_expected_none() { + let dir = root(); + let rel = Path::new("sub/new.txt"); + std::fs::create_dir_all(dir.path().join("sub")).unwrap(); + atomic_write(dir.path(), rel, None, b"created").unwrap(); + assert_eq!(std::fs::read(dir.path().join(rel)).unwrap(), b"created"); + // Creating over an existing file is refused. + let err = atomic_write(dir.path(), rel, None, b"again").unwrap_err(); + assert!(!err.replacement_completed); + assert_eq!(err.source.kind(), io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read(dir.path().join(rel)).unwrap(), b"created"); + // Overwriting without the expected bytes is refused. + let err = atomic_write(dir.path(), rel, Some(b"stale"), b"x").unwrap_err(); + assert_eq!(err.source.kind(), io::ErrorKind::InvalidData); + assert_eq!(std::fs::read(dir.path().join(rel)).unwrap(), b"created"); + // Overwriting a missing file with expected bytes is refused. + let err = + atomic_write(dir.path(), Path::new("sub/gone.txt"), Some(b"old"), b"x").unwrap_err(); + assert!(!err.replacement_completed); + } + + #[cfg(unix)] + #[test] + fn preserves_mode_bits() { + use std::os::unix::fs::PermissionsExt; + for mode in [0o600, 0o700, 0o755] { + let dir = root(); + let rel = Path::new("f.sh"); + let abs = dir.path().join(rel); + std::fs::write(&abs, b"old").unwrap(); + std::fs::set_permissions(&abs, std::fs::Permissions::from_mode(mode)).unwrap(); + atomic_write(dir.path(), rel, Some(b"old"), b"new").unwrap(); + let got = std::fs::symlink_metadata(&abs) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(got, mode, "mode {mode:o} must survive replacement"); + assert_eq!(std::fs::read(&abs).unwrap(), b"new"); + } + } + + #[test] + fn rejects_escape_and_absolute_paths() { + let dir = root(); + for bad in [ + "../outside", + "a/../../outside", + "..", + "/absolute/path", + "", + ".", + ] { + let err = atomic_write(dir.path(), Path::new(bad), None, b"x").unwrap_err(); + assert!(!err.replacement_completed, "{bad}"); + assert_eq!(err.source.kind(), io::ErrorKind::InvalidInput, "{bad}"); + } + } + + #[cfg(unix)] + #[test] + fn rejects_symlink_destination_and_ancestors() { + use std::os::unix::fs::symlink; + let dir = root(); + let outside = root(); + let sentinel = outside.path().join("sentinel"); + std::fs::write(&sentinel, b"KEEP").unwrap(); + + // Symlink destination. + symlink(&sentinel, dir.path().join("link.txt")).unwrap(); + let err = atomic_write(dir.path(), Path::new("link.txt"), None, b"x").unwrap_err(); + assert_eq!(err.source.kind(), io::ErrorKind::InvalidInput); + let err = atomic_write(dir.path(), Path::new("link.txt"), Some(b"KEEP"), b"x").unwrap_err(); + assert_eq!(err.source.kind(), io::ErrorKind::InvalidInput); + assert_eq!(std::fs::read(&sentinel).unwrap(), b"KEEP"); + + // Symlink parent directory. + std::fs::create_dir(outside.path().join("real")).unwrap(); + symlink(outside.path().join("real"), dir.path().join("linkdir")).unwrap(); + let err = atomic_write(dir.path(), Path::new("linkdir/f.txt"), None, b"x").unwrap_err(); + assert_eq!(err.source.kind(), io::ErrorKind::InvalidInput); + assert!(!outside.path().join("real/f.txt").exists()); + } + + #[test] + fn rejects_non_regular_destination() { + let dir = root(); + std::fs::create_dir(dir.path().join("adir")).unwrap(); + let err = atomic_write(dir.path(), Path::new("adir"), None, b"x").unwrap_err(); + assert_eq!(err.source.kind(), io::ErrorKind::InvalidInput); + // Directory mistaken for an expected file is also refused. + let err = atomic_write(dir.path(), Path::new("adir"), Some(b""), b"x").unwrap_err(); + assert_eq!(err.source.kind(), io::ErrorKind::InvalidInput); + } + + #[cfg(unix)] + #[test] + fn fifo_destination_is_refused() { + let dir = root(); + let fifo = dir.path().join("pipe"); + let status = std::process::Command::new("mkfifo") + .arg(&fifo) + .status() + .expect("mkfifo available"); + assert!(status.success()); + let err = atomic_write(dir.path(), Path::new("pipe"), None, b"x").unwrap_err(); + assert_eq!(err.source.kind(), io::ErrorKind::InvalidInput); + } + + #[test] + fn preserves_existing_predictable_temp_contents() { + let dir = root(); + std::fs::write(dir.path().join("a.rs"), b"old").unwrap(); + std::fs::write(dir.path().join(".a.rs.licet.tmp"), b"PREDICTABLE").unwrap(); + atomic_write(dir.path(), Path::new("a.rs"), Some(b"old"), b"new").unwrap(); + assert_eq!( + std::fs::read(dir.path().join(".a.rs.licet.tmp")).unwrap(), + b"PREDICTABLE" + ); + assert_eq!(std::fs::read(dir.path().join("a.rs")).unwrap(), b"new"); + } + + #[test] + fn two_destinations_do_not_collide() { + let dir = root(); + std::fs::write(dir.path().join("a.txt"), b"a").unwrap(); + std::fs::write(dir.path().join("b.txt"), b"b").unwrap(); + atomic_write(dir.path(), Path::new("a.txt"), Some(b"a"), b"A").unwrap(); + atomic_write(dir.path(), Path::new("b.txt"), Some(b"b"), b"B").unwrap(); + assert_eq!(std::fs::read(dir.path().join("a.txt")).unwrap(), b"A"); + assert_eq!(std::fs::read(dir.path().join("b.txt")).unwrap(), b"B"); + } + + #[test] + fn post_persist_sync_failure_reports_committed_write() { + let dir = root(); + let rel = Path::new("f.txt"); + std::fs::write(dir.path().join(rel), b"old").unwrap(); + let canon_dest = dir.path().canonicalize().unwrap().join(rel); + __licet_test_force_post_persist_sync_failure_for(&canon_dest); + let err = atomic_write(dir.path(), rel, Some(b"old"), b"new").unwrap_err(); + assert!( + err.replacement_completed, + "post-persist failure must flag the committed write" + ); + assert_eq!(std::fs::read(dir.path().join(rel)).unwrap(), b"new"); + } + + #[cfg(unix)] + #[test] + fn unreadable_destination_is_an_error_not_absence() { + use std::os::unix::fs::PermissionsExt; + let dir = root(); + let rel = Path::new("locked.txt"); + let abs = dir.path().join(rel); + std::fs::write(&abs, b"secret").unwrap(); + std::fs::set_permissions(&abs, std::fs::Permissions::from_mode(0o000)).unwrap(); + let res = atomic_write(dir.path(), rel, None, b"x"); + // Restore so the temp dir can be cleaned up. + std::fs::set_permissions(&abs, std::fs::Permissions::from_mode(0o600)).unwrap(); + if res.is_ok() { + // Running as root: permission bits do not restrict us; nothing to assert. + return; + } + let err = res.unwrap_err(); + assert!(!err.replacement_completed); + } + + #[test] + fn read_expected_distinguishes_absence_from_failure() { + let dir = root(); + assert_eq!( + read_expected_for_write(&dir.path().join("missing")).unwrap(), + None + ); + std::fs::write(dir.path().join("f"), b"data").unwrap(); + assert_eq!( + read_expected_for_write(&dir.path().join("f")).unwrap(), + Some(b"data".to_vec()) + ); + } +} diff --git a/src/reuse/inventory.rs b/src/reuse/inventory.rs index 9c69854..177ee3c 100644 --- a/src/reuse/inventory.rs +++ b/src/reuse/inventory.rs @@ -1,10 +1,57 @@ //! License-text inventory and offline materialization into `LICENSES/` (FR-014, FR-017). +//! +//! Every identifier is validated before any filesystem mutation: known SPDX +//! license/exception ids (canonicalized spelling) are materialized from the +//! embedded bundle offline; valid-but-unbundled standard ids may be fetched with +//! the system `curl` binary only under explicit `--allow-network` consent (into +//! owned temporary storage, never through the destination); syntactically valid +//! `LicenseRef-*` ids are reported as required local texts, never downloaded +//! and never scaffolded with placeholder prose. Anything else is a usage error +//! (exit 2) raised before any directory is created or any subprocess is spawned. use std::collections::BTreeSet; +use std::io; use std::path::Path; +use crate::reuse::atomic_write; use crate::spdx; +/// Maximum accepted downloaded license-text size (4 MiB). +pub const MAX_DOWNLOAD_BYTES: usize = 4 * 1024 * 1024; + +/// Canonical download location for a standard SPDX id. +pub fn download_url(id: &str) -> String { + format!("https://spdx.org/licenses/{id}.txt") +} + +/// A `LICENSES/` entry that names no recognizable license. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnrecognizedText { + /// Snapshot-relative path (`LICENSES/Unknown-Thing.txt`). + pub path: String, + pub reason: String, +} + +/// A recognized `LICENSES/` text that could not be validated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnreadableText { + /// Snapshot-relative path. + pub path: String, + pub reason: String, +} + +/// Why a text inventory cannot be computed at all. +#[derive(Debug, thiserror::Error)] +pub enum InventoryError { + /// The same license id is claimed by several filenames (the reference + /// tool aborts on this too — the inventory is ambiguous). + #[error("duplicate license texts for `{id}`: {paths:?}")] + DuplicateIds { id: String, paths: Vec }, + /// The snapshot could not supply a text (merge state or prefetch gap). + #[error("cannot read license text `{path}` from the evaluated snapshot: {reason}")] + Snapshot { path: String, reason: String }, +} + /// Referenced/present/missing/bundled inventory over `LICENSES/` and the config. #[derive(Debug, Default)] pub struct LicenseTextInventory { @@ -12,12 +59,109 @@ pub struct LicenseTextInventory { pub present: BTreeSet, pub missing: BTreeSet, pub bundled_available: BTreeSet, + /// Present but never referenced (project-wide lint finding only). + pub unused: Vec, + /// Entries naming no recognizable license id (project-wide lint finding). + pub unrecognized: Vec, + /// Recognized ids kept in an extensionless file: they satisfy + /// materialization and policy presence, but strict REUSE lint reports + /// their missing extension (the spec requires one even though the + /// reference tool accepts some extensionless names). + pub missing_extension: Vec, + /// Recognized texts that could not be read as UTF-8: validation is + /// incomplete for them, never a pass and never a proven violation. + pub unreadable: Vec, } impl LicenseTextInventory { - /// Compute the inventory for a set of referenced identifiers at `root`. - pub fn compute(root: &Path, referenced: &BTreeSet) -> Self { - let present = present_texts(root); + /// Compute the inventory for referenced identifiers through `snapshot`, so + /// staged checks observe staged texts. + /// + /// Only the root `LICENSES/` directory's top level is inventoried. + /// Recognized filename forms are `.txt`, `.md`, and the bare + /// `` (extensionless, flagged for lint). Anything else is + /// unrecognized. Symlinked entries are ignored: a symlink is not a text + /// the tool can vouch for (F02). Duplicate ids under several filenames + /// are an error, never a silent pick. + pub fn compute( + snapshot: &crate::walk::Snapshot, + referenced: &BTreeSet, + ) -> Result { + // id -> snapshot-relative paths claiming it. + let mut by_id: std::collections::BTreeMap> = Default::default(); + let mut unrecognized = Vec::new(); + let mut unreadable = Vec::new(); + let mut missing_extension = Vec::new(); + for rel in snapshot.license_text_candidates() { + let display = rel.to_string_lossy().replace('\\', "/"); + let name = match rel.file_name().and_then(|n| n.to_str()) { + Some(n) => n.to_string(), + None => { + unrecognized.push(UnrecognizedText { + path: display, + reason: "filename is not valid UTF-8".to_string(), + }); + continue; + } + }; + let (stem, suffixed) = match classify_text_filename(&name) { + TextName::Recognized { id, suffixed } => (id, suffixed), + TextName::Unrecognized { reason } => { + unrecognized.push(UnrecognizedText { + path: display, + reason, + }); + continue; + } + }; + // A recognized text must be readable UTF-8 to count; undecodable + // bytes are incomplete validation, never presence. A vanishing + // file (worktree race) simply does not count. Index-snapshot + // failures are snapshot inconsistencies, not file findings. + // Presence is about naming and readability, never content + // judgment: an empty but correctly named text counts (the + // reference tool likewise reports no finding for it), because + // licet never claims to prove legal correctness of file + // contents. + match snapshot.read(&rel) { + Ok(Some(bytes)) => { + if std::str::from_utf8(&bytes).is_err() { + unreadable.push(UnreadableText { + path: display, + reason: "license text is not valid UTF-8".to_string(), + }); + continue; + } + } + Ok(None) => continue, + Err(e) if matches!(snapshot.source(), crate::domain::ContentSource::Worktree) => { + unreadable.push(UnreadableText { + path: display, + reason: format!("cannot read license text: {e}"), + }); + continue; + } + Err(e) => { + return Err(InventoryError::Snapshot { + path: display, + reason: e.to_string(), + }); + } + } + if !suffixed { + missing_extension.push(stem.clone()); + } + by_id.entry(stem).or_default().push(display); + } + for (id, paths) in &by_id { + if paths.len() > 1 { + return Err(InventoryError::DuplicateIds { + id: id.clone(), + paths: paths.clone(), + }); + } + } + let present: BTreeSet = by_id.keys().cloned().collect(); let mut missing = BTreeSet::new(); let mut bundled_available = BTreeSet::new(); for id in referenced { @@ -28,45 +172,334 @@ impl LicenseTextInventory { bundled_available.insert(id.clone()); } } - LicenseTextInventory { + let unused: Vec = present + .iter() + .filter(|id| !referenced.contains(*id)) + .cloned() + .collect(); + missing_extension.retain(|id| present.contains(id)); + missing_extension.sort(); + missing_extension.dedup(); + Ok(LicenseTextInventory { referenced: referenced.clone(), present, missing, bundled_available, - } + unused, + unrecognized, + missing_extension, + unreadable, + }) } - /// True when every referenced text is present (REUSE text-existence check). + /// True when every referenced text is present **and** no text-level + /// finding (unrecognized, missing extension, unreadable, unused) remains. + /// `check` uses only `missing`; full lint uses this. pub fn is_complete(&self) -> bool { self.missing.is_empty() + && self.unused.is_empty() + && self.unrecognized.is_empty() + && self.missing_extension.is_empty() + && self.unreadable.is_empty() + } +} + +/// How a `LICENSES/` filename resolves to a license id. +enum TextName { + Recognized { id: String, suffixed: bool }, + Unrecognized { reason: String }, +} + +/// Classify one `LICENSES/` filename: `.txt` / `.md` (suffixed), +/// bare `` (recognized but extensionless), or unrecognized. Standard ids +/// match case-insensitively to canonical spelling; `LicenseRef-*` matches +/// exactly (custom ids keep their case). +fn classify_text_filename(name: &str) -> TextName { + // Strip one recognized suffix; anything else must be the bare id itself. + let (stem, suffixed) = if let Some(stem) = name.strip_suffix(".txt") { + (stem, true) + } else if let Some(stem) = name.strip_suffix(".md") { + (stem, true) + } else if name.contains('.') { + return TextName::Unrecognized { + reason: format!("unsupported license-text suffix in `{name}` (expected .txt or .md)"), + }; + } else { + (name, false) + }; + if stem.is_empty() { + return TextName::Unrecognized { + reason: format!("empty license id in `{name}`"), + }; + } + if is_valid_license_ref(stem) { + return TextName::Recognized { + id: stem.to_string(), + suffixed, + }; + } + // Legacy `GPL-2.0+`-style filenames are not normalized: the reference tool + // treats them as a deprecated id that satisfies nothing, and this tool + // rejects deprecated ids everywhere, so such a text can never satisfy a + // reference. Point at the canonical `-only`/`-or-later` name instead. + if stem.ends_with('+') { + return TextName::Unrecognized { + reason: format!( + "`{stem}` uses a legacy `+` suffix; name the text with the canonical id instead" + ), + }; + } + if let Some(canonical) = canonical_standard_id(stem) { + return TextName::Recognized { + id: canonical, + suffixed, + }; + } + if let Some(exc) = ::spdx::exception_id(stem) { + return TextName::Recognized { + id: exc.name.to_string(), + suffixed, + }; + } + TextName::Unrecognized { + reason: format!("`{stem}` is not a recognized SPDX license id, exception, or LicenseRef-*"), + } +} + +/// A validated materialization target: what an id means and where its file lives. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ValidatedId { + /// Standard SPDX license id with bundled text (canonical spelling). + Bundled { canonical: String }, + /// Standard SPDX license/exception id absent from the bundle; fetchable over + /// HTTPS under explicit network consent. + Fetchable { canonical: String }, + /// Custom `LicenseRef-*`: the maintainer must supply the text locally. + CustomRef { id: String }, +} + +/// Why an `add-license` identifier is rejected (usage error, exit 2). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InvalidId { + pub id: String, + pub reason: String, +} + +impl std::fmt::Display for InvalidId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "invalid license id `{}`: {}", self.id, self.reason) + } +} + +impl std::error::Error for InvalidId {} + +/// Validate one materialization id without touching the filesystem. +/// +/// Canonicalizes standard-id spelling; rejects separators, absolute paths, +/// control bytes, leading dashes (option injection into subprocesses), and +/// compound expressions passed as a single id. +pub fn validate_materialize_id(id: &str) -> Result { + let bad = |reason: &str| { + Err(InvalidId { + id: id.to_string(), + reason: reason.to_string(), + }) + }; + if id.is_empty() { + return bad("identifier is empty"); + } + if id.starts_with('-') { + return bad("identifier must not start with `-`"); + } + if id.contains(['/', '\\']) || Path::new(id).is_absolute() { + return bad("identifier must not contain path separators or be absolute"); + } + if id.chars().any(|c| c.is_control()) { + return bad("identifier must not contain control characters"); + } + if id.chars().any(|c| c.is_whitespace()) { + return bad("compound SPDX expressions cannot be materialized as one id"); + } + if let Some(canonical) = canonical_standard_id(id) { + if spdx::bundled_text(&canonical).is_some() { + return Ok(ValidatedId::Bundled { canonical }); + } + return Ok(ValidatedId::Fetchable { canonical }); } + if let Some(exc) = ::spdx::exception_id(id) { + return Ok(ValidatedId::Fetchable { + canonical: exc.name.to_string(), + }); + } + if is_valid_license_ref(id) { + return Ok(ValidatedId::CustomRef { id: id.to_string() }); + } + bad("unknown SPDX license id, exception, or LicenseRef-*") +} + +/// Canonical spelling of a standard license id: exact lookup first, then a +/// full-length imprecise match for the promised case tolerance (`mit` → `MIT`). +/// Prefix-only matches (`MITX`) are rejected by the length check. +fn canonical_standard_id(id: &str) -> Option { + if let Some(lic) = ::spdx::license_id(id) { + return Some(lic.name.to_string()); + } + if let Some((lic, matched)) = ::spdx::imprecise_license_id(id) + && matched == id.len() + { + return Some(lic.name.to_string()); + } + None } -/// Identifiers whose text files exist under `LICENSES/`. -fn present_texts(root: &Path) -> BTreeSet { - let mut present = BTreeSet::new(); - let dir = root.join("LICENSES"); - if let Ok(entries) = std::fs::read_dir(&dir) { - for e in entries.flatten() { - let path = e.path(); - if path.extension().map(|x| x == "txt").unwrap_or(false) - && let Some(stem) = path.file_stem().and_then(|s| s.to_str()) - { - present.insert(stem.to_string()); +/// Syntactic shape of a custom reference: `LicenseRef-` plus a nonempty body of +/// alphanumerics, `.`, `-`, `+` (custom ids keep their exact case). +fn is_valid_license_ref(id: &str) -> bool { + match id.strip_prefix("LicenseRef-") { + Some(body) => { + !body.is_empty() + && body + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+')) + } + None => false, + } +} + +/// Failure to materialize license texts. +#[derive(Debug, thiserror::Error)] +pub enum MaterializeError { + #[error("{0}")] + InvalidId(#[from] InvalidId), + #[error("{0}")] + Inventory(#[from] InventoryError), + #[error("cannot materialize license texts: {0}")] + Io(#[from] io::Error), +} + +impl From for MaterializeError { + fn from(w: crate::reuse::WriteError) -> Self { + MaterializeError::Io(w.source) + } +} + +/// One required license text with no plannable write: the id plus why it is +/// blocked (a custom text nobody supplied, or a fetch `apply` never runs). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlockedText { + pub id: String, + pub message: String, +} + +/// Plan `LICENSES/` installs without writing anything (FR-014, FR-017). +/// +/// Validates the whole requested list first, exactly like [`materialize`]: +/// bundled-but-missing ids become creation-only [`PlannedWrite`]s +/// (`LicenseText`); valid-but-unbundled standard ids become explicit blockers +/// naming their download URL (a required fetch that has not occurred — +/// `apply`, dry-run or real, never fetches); custom `LicenseRef-*` ids with +/// no local text become explicit blockers naming the expected path. Texts +/// already present yield no records at all. +pub fn plan_text_writes( + root: &Path, + referenced: &BTreeSet, +) -> Result<(Vec, Vec), MaterializeError> { + use crate::domain::{PlannedWrite, WriteKind}; + let mut targets: Vec = Vec::with_capacity(referenced.len()); + for id in referenced { + targets.push(validate_materialize_id(id)?); + } + let canonical_refs: BTreeSet = targets + .iter() + .map(|t| match t { + ValidatedId::Bundled { canonical } | ValidatedId::Fetchable { canonical } => { + canonical.clone() } + ValidatedId::CustomRef { id } => id.clone(), + }) + .collect(); + let worktree = crate::walk::Snapshot::Worktree { + root: root.to_path_buf(), + }; + let inv = LicenseTextInventory::compute(&worktree, &canonical_refs)?; + + let mut writes = Vec::new(); + let mut blocked = Vec::new(); + for target in &targets { + let (file_id, kind) = match target { + ValidatedId::Bundled { canonical } => (canonical.clone(), "bundled"), + ValidatedId::Fetchable { canonical } => (canonical.clone(), "fetchable"), + ValidatedId::CustomRef { id } => (id.clone(), "custom"), + }; + if !inv.missing.contains(&file_id) { + continue; + } + match target { + ValidatedId::Bundled { canonical } => match spdx::bundled_text(canonical) { + Some(text) => writes.push(PlannedWrite { + path: Path::new("LICENSES").join(format!("{file_id}.txt")), + kind: WriteKind::LicenseText, + before: None, + after: Some(text.as_bytes().to_vec()), + affected_files: Vec::new(), + }), + None => blocked.push(BlockedText { + id: file_id.clone(), + message: format!( + "`{file_id}` ({kind}) validates but the bundle carries no text for it; \ + add LICENSES/{file_id}.txt manually" + ), + }), + }, + ValidatedId::Fetchable { canonical } => blocked.push(BlockedText { + id: file_id, + message: format!( + "no offline text for `{canonical}` (fetch {} to LICENSES/{canonical}.txt \ + with `add-license --allow-network`; dry-run and apply never fetch)", + download_url(canonical) + ), + }), + ValidatedId::CustomRef { id } => blocked.push(BlockedText { + id: id.clone(), + message: format!( + "no local text for `{id}` (add LICENSES/{id}.txt manually); custom texts \ + are never downloaded or scaffolded" + ), + }), } } - present + Ok((writes, blocked)) } -/// Materialize referenced-but-missing texts into `LICENSES/` from the offline bundle; -/// scaffold `LicenseRef-*` placeholders (FR-017). Returns ids written and ids still missing. +/// Materialize referenced-but-missing texts into `LICENSES/` from the offline bundle. +/// +/// Validates the **whole** requested list before creating any directory. Writes +/// go through the shared safe writer (creation only — an existing text is never +/// overwritten). `LicenseRef-*` and unbundled standard ids are left in +/// `still_missing` for the caller to report or fetch; no placeholder prose is +/// ever invented. Returns ids written and ids still missing. pub fn materialize( root: &Path, referenced: &BTreeSet, -) -> std::io::Result { - let dir = root.join("LICENSES"); - let inv = LicenseTextInventory::compute(root, referenced); +) -> Result { + let mut targets: Vec = Vec::with_capacity(referenced.len()); + for id in referenced { + targets.push(validate_materialize_id(id)?); + } + // Inventory over canonical file ids (`mit` is satisfied by `MIT.txt`). + let canonical_refs: BTreeSet = targets + .iter() + .map(|t| match t { + ValidatedId::Bundled { canonical } | ValidatedId::Fetchable { canonical } => { + canonical.clone() + } + ValidatedId::CustomRef { id } => id.clone(), + }) + .collect(); + let worktree = crate::walk::Snapshot::Worktree { + root: root.to_path_buf(), + }; + let inv = LicenseTextInventory::compute(&worktree, &canonical_refs)?; let mut written = Vec::new(); let mut still_missing = Vec::new(); @@ -76,24 +509,31 @@ pub fn materialize( still_missing, }); } - std::fs::create_dir_all(&dir)?; - for id in &inv.missing { - let dest = dir.join(format!("{id}.txt")); - if let Some(text) = spdx::bundled_text(id) { - std::fs::write(&dest, text)?; - written.push(id.clone()); - } else if spdx::is_license_ref(id) { - // Scaffold a placeholder for the maintainer to fill. - let placeholder = format!( - "{id}\n\nTODO: provide the full text of the custom license `{id}`.\n\ - This placeholder satisfies REUSE's text-existence check but is flagged by\n\ - `licet lint` until filled.\n" - ); - std::fs::write(&dest, placeholder)?; - written.push(id.clone()); - } else { - still_missing.push(id.clone()); - } + std::fs::create_dir_all(root.join("LICENSES"))?; + for target in &targets { + let (file_id, text) = match target { + ValidatedId::Bundled { canonical } if inv.missing.contains(canonical) => { + match spdx::bundled_text(canonical) { + Some(text) => (canonical.clone(), text), + None => { + still_missing.push(canonical.clone()); + continue; + } + } + } + ValidatedId::Fetchable { canonical } => { + still_missing.push(canonical.clone()); + continue; + } + ValidatedId::CustomRef { id } => { + still_missing.push(id.clone()); + continue; + } + _ => continue, + }; + let rel = Path::new("LICENSES").join(format!("{file_id}.txt")); + atomic_write(root, &rel, None, text.as_bytes())?; + written.push(file_id); } Ok(MaterializeResult { written, @@ -102,11 +542,82 @@ pub fn materialize( } /// Outcome of [`materialize`]. +#[derive(Debug)] pub struct MaterializeResult { pub written: Vec, pub still_missing: Vec, } +/// Fetch one standard license text over HTTPS with the system `curl` binary. +/// +/// The download lands in owned temporary storage (never the destination): bounded +/// execution (`--connect-timeout 10`, `--max-time 60`, 4 MiB cap enforced in-code +/// as well as via `--max-filesize`), HTTPS-only initial and redirect protocols, +/// `--disable` first so user curl configuration cannot change behavior. The result +/// must be nonempty UTF-8 text. Nothing is deleted or installed on failure — the +/// caller installs successful bytes through the shared safe writer. +pub fn fetch_text_via_curl(id: &str) -> Result, FetchError> { + let url = download_url(id); + let tmp = tempfile::tempdir().map_err(FetchError::Launch)?; + let out_path = tmp.path().join("license.txt"); + let output = std::process::Command::new("curl") + .arg("--disable") + .arg("--fail") + .arg("--silent") + .arg("--show-error") + .arg("--location") + .arg("--proto") + .arg("=https") + .arg("--proto-redir") + .arg("=https") + .arg("--connect-timeout") + .arg("10") + .arg("--max-time") + .arg("60") + .arg("--max-filesize") + .arg(MAX_DOWNLOAD_BYTES.to_string()) + .arg("--output") + .arg(&out_path) + .arg(&url) + .output() + .map_err(FetchError::Launch)?; + if !output.status.success() { + return Err(FetchError::Failed { + status: output.status.code(), + stderr: String::from_utf8_lossy(&output.stderr) + .chars() + .take(500) + .collect(), + }); + } + let bytes = std::fs::read(&out_path).map_err(FetchError::Launch)?; + if bytes.is_empty() { + return Err(FetchError::Empty); + } + if bytes.len() > MAX_DOWNLOAD_BYTES { + return Err(FetchError::TooLarge { bytes: bytes.len() }); + } + if std::str::from_utf8(&bytes).is_err() { + return Err(FetchError::InvalidUtf8); + } + Ok(bytes) +} + +/// A failed license-text download. The destination is always untouched. +#[derive(Debug, thiserror::Error)] +pub enum FetchError { + #[error("could not run curl for the download: {0}")] + Launch(#[source] io::Error), + #[error("curl exited with status {status:?}: {stderr}")] + Failed { status: Option, stderr: String }, + #[error("downloaded license text is empty")] + Empty, + #[error("downloaded license text is {bytes} bytes (limit {MAX_DOWNLOAD_BYTES})")] + TooLarge { bytes: usize }, + #[error("downloaded license text is not valid UTF-8")] + InvalidUtf8, +} + #[cfg(test)] mod tests { use super::*; @@ -123,15 +634,58 @@ mod tests { } #[test] - fn scaffolds_license_ref_placeholder() { + fn canonicalizes_standard_id_spelling() { let dir = tempdir().unwrap(); let mut refs = BTreeSet::new(); - refs.insert("LicenseRef-Marque-1.0".to_string()); + refs.insert("mit".to_string()); let res = materialize(dir.path(), &refs).unwrap(); - assert_eq!(res.written.len(), 1); - let text = - std::fs::read_to_string(dir.path().join("LICENSES/LicenseRef-Marque-1.0.txt")).unwrap(); - assert!(text.contains("TODO")); + assert_eq!(res.written, vec!["MIT".to_string()]); + assert!(dir.path().join("LICENSES/MIT.txt").exists()); + } + + #[test] + fn license_ref_reports_required_text_without_scaffolding() { + let dir = tempdir().unwrap(); + let mut refs = BTreeSet::new(); + refs.insert("LicenseRef-Acme-1.0".to_string()); + let res = materialize(dir.path(), &refs).unwrap(); + assert!(res.written.is_empty()); + assert_eq!(res.still_missing, vec!["LicenseRef-Acme-1.0".to_string()]); + // No placeholder prose is invented for custom licenses. + assert!(!dir.path().join("LICENSES/LicenseRef-Acme-1.0.txt").exists()); + } + + #[test] + fn invalid_ids_fail_before_any_directory_exists() { + for bad in [ + "../victim", + "/absolute", + "a/b", + "MIT OR Apache-2.0", + "Definitely-Not-A-License-9.9", + "", + "-n", + "MIT\x07", + ] { + let dir = tempdir().unwrap(); + let mut refs = BTreeSet::new(); + refs.insert(bad.to_string()); + let err = materialize(dir.path(), &refs).unwrap_err(); + assert!( + matches!(err, MaterializeError::InvalidId(_)), + "{bad:?} must be a validation error, got {err:?}" + ); + assert!( + !dir.path().join("LICENSES").exists(), + "{bad:?} must not create any directory" + ); + } + } + + fn worktree_snapshot(dir: &tempfile::TempDir) -> crate::walk::Snapshot { + crate::walk::Snapshot::Worktree { + root: dir.path().to_path_buf(), + } } #[test] @@ -139,9 +693,113 @@ mod tests { let dir = tempdir().unwrap(); let mut refs = BTreeSet::new(); refs.insert("MIT".to_string()); - let inv = LicenseTextInventory::compute(dir.path(), &refs); + let snap = worktree_snapshot(&dir); + let inv = LicenseTextInventory::compute(&snap, &refs).unwrap(); assert!(inv.missing.contains("MIT")); assert!(inv.bundled_available.contains("MIT")); assert!(!inv.is_complete()); } + + #[test] + fn symlinked_text_is_not_present() { + let dir = tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("LICENSES")).unwrap(); + let outside = tempdir().unwrap(); + let target = outside.path().join("MIT.txt"); + std::fs::write(&target, "external").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&target, dir.path().join("LICENSES/MIT.txt")).unwrap(); + #[cfg(windows)] + std::os::windows::fs::symlink_file(&target, dir.path().join("LICENSES/MIT.txt")).unwrap(); + let mut refs = BTreeSet::new(); + refs.insert("MIT".to_string()); + let snap = worktree_snapshot(&dir); + let inv = LicenseTextInventory::compute(&snap, &refs).unwrap(); + assert!( + inv.missing.contains("MIT"), + "symlinked text must not count as present" + ); + } + + #[test] + fn duplicate_ids_are_an_error() { + let dir = tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("LICENSES")).unwrap(); + std::fs::write(dir.path().join("LICENSES/MIT.txt"), "text").unwrap(); + std::fs::write(dir.path().join("LICENSES/MIT.md"), "text").unwrap(); + let snap = worktree_snapshot(&dir); + let err = LicenseTextInventory::compute(&snap, &BTreeSet::new()).unwrap_err(); + assert!(err.to_string().contains("MIT"), "names the id: {err}"); + } + + #[test] + fn extensionless_known_id_counts_but_is_flagged() { + let dir = tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("LICENSES")).unwrap(); + std::fs::write(dir.path().join("LICENSES/MIT"), "text").unwrap(); + let mut refs = BTreeSet::new(); + refs.insert("MIT".to_string()); + let snap = worktree_snapshot(&dir); + let inv = LicenseTextInventory::compute(&snap, &refs).unwrap(); + assert!( + inv.missing.is_empty(), + "extensionless text satisfies presence" + ); + assert_eq!(inv.missing_extension, vec!["MIT".to_string()]); + assert!( + !inv.is_complete(), + "lint still reports the missing extension" + ); + } + + #[test] + fn dotted_licenseref_round_trips() { + let dir = tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("LICENSES")).unwrap(); + std::fs::write( + dir.path().join("LICENSES/LicenseRef-Acme-1.0.txt"), + "custom", + ) + .unwrap(); + let mut refs = BTreeSet::new(); + refs.insert("LicenseRef-Acme-1.0".to_string()); + let snap = worktree_snapshot(&dir); + let inv = LicenseTextInventory::compute(&snap, &refs).unwrap(); + assert!(inv.missing.is_empty()); + assert!(inv.present.contains("LicenseRef-Acme-1.0")); + } + + #[test] + fn unknown_and_bad_suffixes_are_unrecognized() { + let dir = tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("LICENSES")).unwrap(); + std::fs::write(dir.path().join("LICENSES/Unknown-Thing.txt"), "x").unwrap(); + std::fs::write(dir.path().join("LICENSES/MIT.txt.txt"), "x").unwrap(); + std::fs::write(dir.path().join("LICENSES/GPL-2.0+.txt"), "x").unwrap(); + let snap = worktree_snapshot(&dir); + let inv = LicenseTextInventory::compute(&snap, &BTreeSet::new()).unwrap(); + assert_eq!(inv.unrecognized.len(), 3); + assert!( + inv.unused.is_empty(), + "unrecognized entries are not licenses" + ); + assert!(!inv.is_complete()); + } + + #[test] + fn non_utf8_text_is_unreadable_not_present() { + let dir = tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("LICENSES")).unwrap(); + std::fs::write(dir.path().join("LICENSES/MIT.txt"), [0xffu8, 0xfe]).unwrap(); + let mut refs = BTreeSet::new(); + refs.insert("MIT".to_string()); + let snap = worktree_snapshot(&dir); + let inv = LicenseTextInventory::compute(&snap, &refs).unwrap(); + assert!( + inv.missing.contains("MIT"), + "undecodable bytes prove nothing" + ); + assert_eq!(inv.unreadable.len(), 1); + assert!(!inv.is_complete()); + } } diff --git a/src/reuse/mod.rs b/src/reuse/mod.rs index c6bba6d..631953b 100644 --- a/src/reuse/mod.rs +++ b/src/reuse/mod.rs @@ -1,49 +1,8 @@ //! REUSE-compatibility surface: out-of-band metadata, license-text inventory, and atomic //! file writes (FR-014..FR-017, FR-024). +pub mod atomic; pub mod inventory; pub mod oob; -use std::path::Path; - -/// Write `content` to `path` atomically: write a sibling temp file, fsync, then rename -/// over the original so an interruption never leaves a half-written file (FR-024, SC-010). -pub fn atomic_write(path: &Path, content: &str) -> std::io::Result<()> { - use std::io::Write; - let dir = path.parent().unwrap_or_else(|| Path::new(".")); - let file_name = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "tmp".to_string()); - let tmp = dir.join(format!(".{file_name}.licet.tmp")); - - { - let mut f = std::fs::File::create(&tmp)?; - f.write_all(content.as_bytes())?; - f.sync_all()?; - } - std::fs::rename(&tmp, path)?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - #[test] - fn atomic_write_replaces_content() { - let dir = tempdir().unwrap(); - let p = dir.path().join("f.txt"); - std::fs::write(&p, "old").unwrap(); - atomic_write(&p, "new content").unwrap(); - assert_eq!(std::fs::read_to_string(&p).unwrap(), "new content"); - // No temp file left behind. - let leftovers: Vec<_> = std::fs::read_dir(dir.path()) - .unwrap() - .flatten() - .filter(|e| e.file_name().to_string_lossy().contains("licet.tmp")) - .collect(); - assert!(leftovers.is_empty()); - } -} +pub use atomic::{WriteError, atomic_write, read_expected_for_write}; diff --git a/src/reuse/oob.rs b/src/reuse/oob.rs index 873f817..d974dab 100644 --- a/src/reuse/oob.rs +++ b/src/reuse/oob.rs @@ -1,186 +1,681 @@ //! Out-of-band REUSE metadata: read `REUSE.toml` (current spec) and `.reuse/dep5` //! (legacy) for detection, and write `REUSE.toml` annotations for non-annotatable //! files (FR-015, FR-003a, research §7). +//! +//! `REUSE.toml` files are discovered at every directory depth through the same +//! content snapshot the scan evaluates, so staged checks observe staged +//! metadata. Hierarchy resolution mirrors the reference REUSE tool: documents +//! are consulted root-first and stop after the rootmost `override` table; +//! `aggregate` tables always contribute; `closest` tables are a per-field +//! fallback. Every contributing table keeps its provenance in +//! [`crate::domain::MetadataOrigin`]. -use std::path::Path; +use std::path::{Path, PathBuf}; -use globset::{Glob, GlobMatcher}; +use globset::{GlobBuilder, GlobMatcher}; use serde::Deserialize; -use crate::domain::{OobSource, OutOfBandEntry, Precedence}; +use crate::domain::{MetadataOrigin, OobSource, OutOfBandEntry, Precedence}; +use crate::walk::Snapshot; -/// All out-of-band annotations discovered under a repository root. -#[derive(Default)] +/// All out-of-band metadata discovered under a repository root. +#[derive(Debug, Default)] pub struct OutOfBand { - entries: Vec, + /// Parsed `REUSE.toml` documents, shallowest directory first. + docs: Vec, + /// Parsed `.reuse/dep5` paragraphs in document order. + dep5: Vec, + /// Whether `.reuse/dep5` exists in the snapshot (even when it carries no + /// paragraphs — mere coexistence with any `REUSE.toml` is an error). + dep5_present: bool, } -struct OobAnnotation { - matchers: Vec, - license: Option, +/// One parsed `REUSE.toml` document plus where it lives. +#[derive(Debug)] +struct ReuseDoc { + /// Repo-relative path of the document (`REUSE.toml`, `sub/REUSE.toml`). + rel: PathBuf, + /// Directory containing the document (empty for the project root); + /// annotation paths are relative to this base and can never escape it. + base: PathBuf, + tables: Vec, +} + +/// One validated `[[annotations]]` table with its matchers precompiled. +#[derive(Debug)] +struct OobTable { + index: usize, + matchers: Vec, + licenses: Vec, copyrights: Vec, - source: OobSource, precedence: Precedence, } +/// One parsed `.reuse/dep5` paragraph (always `aggregate` per REUSE 3.3 +/// §"Order of precedence": dep5 licensing adds to file-level licensing). +#[derive(Debug)] +struct Dep5Para { + index: usize, + matchers: Vec, + licenses: Vec, + copyrights: Vec, +} + +/// A path matcher: either a compiled glob or a literal fallback for patterns +/// the glob compiler rejects (never a silent drop). +#[derive(Debug)] +enum ReuseMatcher { + Glob(GlobMatcher), + Literal(String), +} + +impl ReuseMatcher { + fn is_match(&self, path: &str) -> bool { + match self { + ReuseMatcher::Glob(g) => g.is_match(path), + ReuseMatcher::Literal(l) => l == path, + } + } +} + #[derive(Debug, Deserialize)] struct ReuseToml { + version: u32, #[serde(default)] annotations: Vec, } #[derive(Debug, Deserialize)] struct ReuseAnnotation { - #[serde(default)] - path: PathList, - #[serde(rename = "SPDX-License-Identifier")] - license: Option, + path: StringList, + #[serde(rename = "SPDX-License-Identifier", default)] + license: StringList, #[serde(rename = "SPDX-FileCopyrightText", default)] - copyright: PathList, - /// REUSE 3.3 `precedence`: `closest` (default) | `aggregate` | `override`. + copyright: StringList, + /// REUSE 3.3 `precedence`: exactly `closest` (default) | `aggregate` | + /// `override`. Anything else is a malformed document, not a silent default. precedence: Option, } -/// Map a REUSE.toml `precedence` string to [`Precedence`] (default `Closest`). -fn parse_precedence(raw: Option<&str>) -> Precedence { - match raw.map(str::trim) { - Some(s) if s.eq_ignore_ascii_case("override") => Precedence::Override, - Some(s) if s.eq_ignore_ascii_case("aggregate") => Precedence::Aggregate, - _ => Precedence::Closest, - } -} - -/// `path` / copyright may be a single string or a list in REUSE.toml. -#[derive(Debug, Default, Deserialize)] +/// A TOML string-or-list field (`path`, licensing, copyright). +#[derive(Debug, Deserialize)] #[serde(untagged)] -enum PathList { - #[default] - None, +enum StringList { One(String), Many(Vec), } -impl PathList { +impl Default for StringList { + fn default() -> Self { + StringList::Many(Vec::new()) + } +} + +impl StringList { fn into_vec(self) -> Vec { match self { - PathList::None => Vec::new(), - PathList::One(s) => vec![s], - PathList::Many(v) => v, + StringList::One(s) => vec![s], + StringList::Many(v) => v, } } } impl OutOfBand { - /// Load `REUSE.toml` and `.reuse/dep5` from `root`, if present. - pub fn load(root: &Path) -> Self { + /// Load `REUSE.toml` documents at every depth plus `.reuse/dep5` from the + /// working tree at `root`. Fallible: malformed metadata is an error with + /// document path and location, never silent absence (F08, F09, F13). + pub fn load(root: &Path) -> crate::error::Result { + Self::load_snapshot(&Snapshot::Worktree { + root: root.to_path_buf(), + }) + } + + /// Load metadata through a content snapshot (staged checks observe staged + /// metadata, never the working copy). + /// + /// Read failures and non-UTF-8 metadata are errors, never silent absence + /// (F04, F13). Malformed documents, a `version` other than 1, a missing + /// `path`, an invalid `precedence`, an invalid license expression, and + /// `REUSE.toml`+`.reuse/dep5` coexistence all fail here — before any + /// write — carrying the document path and parse location. + pub fn load_snapshot(snapshot: &Snapshot) -> crate::error::Result { + use crate::error::LicetError; let mut oob = OutOfBand::default(); - let reuse_toml = root.join("REUSE.toml"); - if let Ok(text) = std::fs::read_to_string(&reuse_toml) { - oob.parse_reuse_toml(&text); + for rel in discover_doc_paths(snapshot)? { + let bytes = snapshot.read(&rel)?.ok_or_else(|| { + LicetError::Config(format!( + "REUSE metadata {} disappeared during the scan", + rel.display() + )) + })?; + let text = String::from_utf8(bytes).map_err(|e| { + LicetError::Config(format!( + "REUSE metadata {} is not valid UTF-8: {e}", + rel.display() + )) + })?; + oob.parse_reuse_toml(&rel, &text)?; } - let dep5 = root.join(".reuse").join("dep5"); - if let Ok(text) = std::fs::read_to_string(&dep5) { - oob.parse_dep5(&text); + let dep5_rel = Path::new(".reuse/dep5"); + if let Some(bytes) = snapshot.read(dep5_rel)? { + oob.dep5_present = true; + let text = String::from_utf8(bytes).map_err(|e| { + LicetError::Config(format!( + "REUSE metadata .reuse/dep5 is not valid UTF-8: {e}" + )) + })?; + oob.parse_dep5(&text)?; } - oob + if oob.dep5_present && !oob.docs.is_empty() { + return Err(LicetError::Config(format!( + "found both '{}' and '.reuse/dep5': REUSE.toml and DEP5 are mutually \ + exclusive, you cannot keep both simultaneously", + display_rel(&oob.docs[0].rel) + ))); + } + Ok(oob) } - fn parse_reuse_toml(&mut self, text: &str) { - let parsed: ReuseToml = match toml::from_str(text) { - Ok(p) => p, - Err(_) => return, - }; - for ann in parsed.annotations { - let precedence = parse_precedence(ann.precedence.as_deref()); - let matchers = compile_globs(ann.path.into_vec()); - self.entries.push(OobAnnotation { + /// Parse one `REUSE.toml` document: structural TOML errors, a `version` + /// other than 1, a missing/empty `path`, an invalid `precedence`, and an + /// invalid license expression all fail with document path and table index. + /// Unknown keys and tables are preserved-ignored per the REUSE schema's + /// extension allowance. + fn parse_reuse_toml(&mut self, rel: &Path, text: &str) -> crate::error::Result<()> { + use crate::error::LicetError; + let loc = display_rel(rel); + let parsed: ReuseToml = toml::from_str(text) + .map_err(|e| LicetError::Config(format!("{loc}: malformed REUSE.toml: {e}")))?; + if parsed.version != 1 { + return Err(LicetError::Config(format!( + "{loc}: unsupported REUSE.toml version {} (this tool implements version 1)", + parsed.version + ))); + } + let base = rel.parent().map(Path::to_path_buf).unwrap_or_default(); + let mut tables = Vec::with_capacity(parsed.annotations.len()); + for (index, ann) in parsed.annotations.into_iter().enumerate() { + let tag = format!("{loc} table [[annotations]] #{index}"); + let paths = ann.path.into_vec(); + if paths.is_empty() { + return Err(LicetError::Config(format!( + "{tag}: 'path' must not be empty" + ))); + } + let precedence = match ann.precedence.as_deref() { + None => Precedence::Closest, + Some("closest") => Precedence::Closest, + Some("aggregate") => Precedence::Aggregate, + Some("override") => Precedence::Override, + Some(other) => { + return Err(LicetError::Config(format!( + "{tag}: invalid precedence {other:?}: must be one of \ + 'closest', 'aggregate', 'override'" + ))); + } + }; + let mut licenses = Vec::new(); + for raw in ann.license.into_vec() { + crate::spdx::validate_expression(&raw).map_err(|reason| { + LicetError::Config(format!("{tag}: invalid SPDX-License-Identifier: {reason}")) + })?; + licenses.push(raw.trim().to_string()); + } + let copyrights = ann + .copyright + .into_vec() + .into_iter() + .map(|c| c.trim().to_string()) + .filter(|c| !c.is_empty()) + .collect(); + let matchers = paths.iter().map(|p| compile_reuse_pattern(p)).collect(); + tables.push(OobTable { + index, matchers, - license: ann.license, - copyrights: ann.copyright.into_vec(), - source: OobSource::ReuseToml, + licenses, + copyrights, precedence, }); } + self.docs.push(ReuseDoc { + rel: rel.to_path_buf(), + base, + tables, + }); + // Keep documents ordered shallowest-first so lookup consults them + // root-first (mirrors the reference tool). + self.docs.sort_by_key(|d| d.base.components().count()); + Ok(()) } - /// Minimal Debian dep5 (`.reuse/dep5`) parser: paragraphs with `Files:`, - /// `Copyright:`, `License:`. - fn parse_dep5(&mut self, text: &str) { - let mut files: Vec = Vec::new(); - let mut license: Option = None; - let mut copyrights: Vec = Vec::new(); - - let flush = |files: &mut Vec, - license: &mut Option, - copyrights: &mut Vec, - entries: &mut Vec| { - if !files.is_empty() { - entries.push(OobAnnotation { - matchers: compile_globs(std::mem::take(files)), - license: license.take(), - copyrights: std::mem::take(copyrights), - source: OobSource::Dep5, - // dep5 has no `precedence` field; REUSE 3.3 (§"Order of precedence") - // specifies its information is *aggregated* with file-level info, so - // both the header's and dep5's licenses apply. - precedence: Precedence::Aggregate, - }); - } - files.clear(); - *license = None; - copyrights.clear(); - }; - - for line in text.lines() { + /// Debian dep5 (`.reuse/dep5`) parser: `Files:`/`Copyright:`/`License:` + /// paragraphs (field names ASCII-case-insensitive) with continuation-line + /// unfolding — a line starting with whitespace continues the current + /// field, and a lone `.` is a blank. Unknown fields are preserved-ignored + /// without being interpreted. An invalid `License:` expression fails like + /// any other malformed metadata. + fn parse_dep5(&mut self, text: &str) -> crate::error::Result<()> { + use crate::error::LicetError; + #[derive(Default)] + struct Para { + files: String, + copyright: Vec, + license: String, + field: Option, + } + #[derive(Clone, Copy, PartialEq, Eq)] + enum Dep5Field { + Files, + Copyright, + License, + Other, + } + let mut paras: Vec = vec![Para::default()]; + for raw_line in text.lines() { + let line = raw_line.strip_suffix('\r').unwrap_or(raw_line); if line.trim().is_empty() { - flush(&mut files, &mut license, &mut copyrights, &mut self.entries); - } else if let Some(rest) = line.strip_prefix("Files:") { - files = rest.split_whitespace().map(dep5_glob).collect(); - } else if let Some(rest) = line.strip_prefix("License:") { - license = Some(rest.trim().to_string()); - } else if let Some(rest) = line.strip_prefix("Copyright:") { - copyrights.push(rest.trim().to_string()); + paras.push(Para::default()); + continue; + } + let para = paras.last_mut().expect("paragraphs never empty"); + if line.starts_with(' ') || line.starts_with('\t') { + // Continuation of the current field (a lone `.` is blank). + let cont = line.trim(); + if cont.is_empty() || cont == "." { + continue; + } + match para.field { + Some(Dep5Field::Files) => { + para.files.push(' '); + para.files.push_str(cont); + } + Some(Dep5Field::Copyright) => para.copyright.push(cont.to_string()), + Some(Dep5Field::License) => { + para.license.push(' '); + para.license.push_str(cont); + } + Some(Dep5Field::Other) | None => {} + } + continue; + } + if cont_is_dot_only(line) { + continue; + } + let (name, value) = match line.split_once(':') { + Some((n, v)) => (n.trim(), v.trim()), + None => continue, + }; + if name.eq_ignore_ascii_case("Files") { + para.field = Some(Dep5Field::Files); + if !para.files.is_empty() { + para.files.push(' '); + } + para.files.push_str(value); + } else if name.eq_ignore_ascii_case("Copyright") { + para.field = Some(Dep5Field::Copyright); + if !value.is_empty() { + para.copyright.push(value.to_string()); + } + } else if name.eq_ignore_ascii_case("License") { + para.field = Some(Dep5Field::License); + if !para.license.is_empty() { + para.license.push(' '); + } + para.license.push_str(value); + } else { + para.field = Some(Dep5Field::Other); + } + } + for (index, para) in paras.into_iter().enumerate() { + if para.files.trim().is_empty() { + continue; + } + let license = para.license.trim(); + let mut licenses = Vec::new(); + if !license.is_empty() { + crate::spdx::validate_expression(license).map_err(|reason| { + LicetError::Config(format!( + ".reuse/dep5 paragraph #{index}: invalid License: {reason}" + )) + })?; + licenses.push(license.to_string()); } + let matchers = para + .files + .split_whitespace() + .map(compile_dep5_pattern) + .collect(); + self.dep5.push(Dep5Para { + index, + matchers, + licenses, + copyrights: para.copyright, + }); } - flush(&mut files, &mut license, &mut copyrights, &mut self.entries); + Ok(()) } /// Out-of-band coverage for a repo-relative path, if any. /// - /// When several annotations match, REUSE 3.3 resolves the overlap by **last match** - /// ("exclusively the last matching table in the file is used"), so we scan in reverse. - /// `REUSE.toml` stays authoritative over legacy `.reuse/dep5`: we prefer the last - /// matching `REUSE.toml` annotation and only fall back to dep5 when none matches. + /// Each document contributes exclusively its last matching table; tables + /// are consulted root-first and consultation stops after the rootmost + /// `override` table (mirroring the reference tool). The returned entry + /// carries unconditional (`aggregate`/barrier) values, per-field + /// `closest` fallbacks, and every contributing table's provenance. pub fn lookup(&self, rel_path: &Path) -> Option { - let matches = |e: &&OobAnnotation| e.matchers.iter().any(|m| m.is_match(rel_path)); - self.entries + let rel_str = rel_path.to_string_lossy().replace('\\', "/"); + // Per-document last match, root-first, stopping after an override. + let mut consulted: Vec<(&ReuseDoc, &OobTable)> = Vec::new(); + for doc in &self.docs { + let Some(remainder) = under_base(&rel_str, &doc.base) else { + continue; + }; + if let Some(table) = doc + .tables + .iter() + .rev() + .find(|t| matches_any(&t.matchers, remainder)) + { + let is_override = table.precedence == Precedence::Override; + consulted.push((doc, table)); + if is_override { + break; + } + } + } + // dep5 paragraphs aggregate; the last matching paragraph wins (as in + // the reference tool), contributing alongside any REUSE tables. + let dep5_hit = self + .dep5 .iter() .rev() - .find(|e| e.source == OobSource::ReuseToml && matches(e)) - .or_else(|| self.entries.iter().rev().find(matches)) - .map(|e| OutOfBandEntry { - source: e.source, - license: e.license.clone(), - copyrights: e.copyrights.clone(), - precedence: e.precedence, - }) + .find(|p| matches_any(&p.matchers, &rel_str)); + if consulted.is_empty() && dep5_hit.is_none() { + return None; + } + let barrier = consulted + .iter() + .any(|(_, t)| t.precedence == Precedence::Override); + let mut licenses: Vec = Vec::new(); + let mut copyrights: Vec = Vec::new(); + let mut origins: Vec = Vec::new(); + let mut primary_source = OobSource::ReuseToml; + let mut has_aggregate = false; + // Unconditional contributors: the barrier table first (if any), then + // every consulted `aggregate` table shallowest-first, then dep5 — + // matching the reference tool's override-before-aggregate order. + let mut unconditional: Vec<(&ReuseDoc, &OobTable)> = Vec::new(); + if barrier { + let (doc, table) = consulted + .last() + .expect("barrier implies a consulted override table"); + debug_assert_eq!(table.precedence, Precedence::Override); + unconditional.push((*doc, *table)); + } + for (doc, table) in &consulted { + if table.precedence == Precedence::Aggregate { + has_aggregate = true; + unconditional.push((*doc, *table)); + } + } + for (doc, table) in unconditional { + push_unique(&mut licenses, table.licenses.iter().cloned()); + push_unique(&mut copyrights, table.copyrights.iter().cloned()); + origins.push(MetadataOrigin { + metadata_path: doc.rel.clone(), + table_index: table.index, + precedence: table.precedence, + licenses: table.licenses.clone(), + copyrights: table.copyrights.clone(), + }); + } + if let Some(para) = dep5_hit { + has_aggregate = true; + if origins.is_empty() { + primary_source = OobSource::Dep5; + } + push_unique(&mut licenses, para.licenses.iter().cloned()); + push_unique(&mut copyrights, para.copyrights.iter().cloned()); + origins.push(MetadataOrigin { + metadata_path: PathBuf::from(".reuse/dep5"), + table_index: para.index, + precedence: Precedence::Aggregate, + licenses: para.licenses.clone(), + copyrights: para.copyrights.clone(), + }); + } + // Per-field nearest-outward `closest` fallback (deepest consulted + // table wins each field independently). + let mut fallback_licenses: Vec = Vec::new(); + let mut fallback_copyrights: Vec = Vec::new(); + let mut fallback_lic_origin: Option = None; + let mut fallback_cpr_origin: Option = None; + for (doc, table) in consulted.iter().rev() { + if table.precedence != Precedence::Closest { + continue; + } + if fallback_licenses.is_empty() && !table.licenses.is_empty() { + fallback_licenses = table.licenses.clone(); + fallback_lic_origin = Some(MetadataOrigin { + metadata_path: doc.rel.clone(), + table_index: table.index, + precedence: table.precedence, + licenses: table.licenses.clone(), + copyrights: table.copyrights.clone(), + }); + } + if fallback_copyrights.is_empty() && !table.copyrights.is_empty() { + fallback_copyrights = table.copyrights.clone(); + fallback_cpr_origin = Some(MetadataOrigin { + metadata_path: doc.rel.clone(), + table_index: table.index, + precedence: table.precedence, + licenses: table.licenses.clone(), + copyrights: table.copyrights.clone(), + }); + } + } + if fallback_lic_origin + .as_ref() + .is_some_and(|o| Some(o) != fallback_cpr_origin.as_ref()) + { + origins.push(fallback_lic_origin.expect("checked")); + } + if let Some(o) = fallback_cpr_origin { + origins.push(o); + } + if licenses.is_empty() + && copyrights.is_empty() + && fallback_licenses.is_empty() + && fallback_copyrights.is_empty() + { + // Tables matched but none carries any licensing information — an + // override barrier still suppresses the file (reference behavior), + // otherwise there is no coverage at all. + if !barrier { + return None; + } + } + // Origins read shallowest-document first (dep5 is root-level). + origins.sort_by_key(|o| (o.metadata_path.clone(), o.table_index)); + let precedence = if barrier { + Precedence::Override + } else if has_aggregate { + Precedence::Aggregate + } else { + Precedence::Closest + }; + Some(OutOfBandEntry { + source: primary_source, + licenses, + copyrights, + fallback_licenses, + fallback_copyrights, + suppresses_file: barrier, + precedence, + origins, + }) } pub fn is_empty(&self) -> bool { - self.entries.is_empty() + self.docs.is_empty() && self.dep5.is_empty() + } + + /// Whether `.reuse/dep5` exists in the loaded snapshot (used to refuse + /// writes that would create a mutually-exclusive `REUSE.toml` next to it). + pub fn has_dep5(&self) -> bool { + self.dep5_present + } +} + +/// A lone `.` line outside a field continuation is blank padding, not content. +fn cont_is_dot_only(line: &str) -> bool { + line.trim() == "." +} + +/// Display a snapshot-relative path with forward slashes. +fn display_rel(rel: &Path) -> String { + rel.to_string_lossy().replace('\\', "/") +} + +/// The portion of repo-relative `path` under document base `base`, or `None` +/// when the file is not inside that directory. Containment holds by +/// construction: a pattern can never match outside its own document's tree. +fn under_base<'a>(path: &'a str, base: &Path) -> Option<&'a str> { + let base_str = display_rel(base); + if base_str.is_empty() { + return Some(path); } + path.strip_prefix(base_str.as_str()) + .and_then(|rest| rest.strip_prefix('/')) + .filter(|rest| !rest.is_empty()) } -/// Translate a dep5 glob (`foo/*`) into a globset pattern. -fn dep5_glob(s: &str) -> String { - s.to_string() +fn matches_any(matchers: &[ReuseMatcher], path: &str) -> bool { + matchers.iter().any(|m| m.is_match(path)) } -fn compile_globs(patterns: Vec) -> Vec { - patterns - .iter() - .filter_map(|p| Glob::new(p).ok().map(|g| g.compile_matcher())) - .collect() +fn push_unique(target: &mut Vec, iter: impl Iterator) { + for item in iter { + if !target.contains(&item) { + target.push(item); + } + } +} + +/// Compile one `REUSE.toml` path pattern with the REUSE 3.3 grammar: `*` +/// never crosses `/`, `**` (and `**/`) does, only `\`-asterisk and +/// `\`-backslash escapes are special, and `?`, brackets, and braces are +/// literal. Everything else is passed through to globset with `/` as the +/// separator, so a pattern can only ever match inside its document's tree. +fn compile_reuse_pattern(pattern: &str) -> ReuseMatcher { + let mut out = String::with_capacity(pattern.len()); + let mut chars = pattern.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\\' => match chars.next() { + // `\` followed by any other character is that character + // verbatim (spec §REUSE.toml); escape it for globset. + Some(next) => { + out.push('\\'); + out.push(next); + } + None => out.push_str("[\\\\]"), + }, + '*' => { + let mut run = 1; + while chars.peek() == Some(&'*') { + chars.next(); + run += 1; + } + out.push_str(if run >= 2 { "**" } else { "*" }); + } + '?' | '[' | ']' | '{' | '}' => { + out.push('\\'); + out.push(c); + } + _ => out.push(c), + } + } + let full = if out.is_empty() { + "**".to_string() + } else { + out + }; + match GlobBuilder::new(&full).literal_separator(true).build() { + Ok(g) => ReuseMatcher::Glob(g.compile_matcher()), + Err(_) => ReuseMatcher::Literal(pattern.to_string()), + } +} + +/// Compile one `.reuse/dep5` `Files:` pattern: shell-style wildcards where +/// `*` crosses `/` (verified against the reference tool), unlike REUSE.toml. +fn compile_dep5_pattern(pattern: &str) -> ReuseMatcher { + match GlobBuilder::new(pattern).literal_separator(false).build() { + Ok(g) => ReuseMatcher::Glob(g.compile_matcher()), + Err(_) => ReuseMatcher::Literal(pattern.to_string()), + } +} + +/// Repo-relative paths of every `REUSE.toml` document visible in `snapshot`: +/// tracked-or-present files at any depth that are not VCS-ignored. Index +/// snapshots observe tracked entries (tracked files are never VCS-ignored); +/// worktree snapshots walk the filesystem honoring ignore rules, never +/// following symlinks. +fn discover_doc_paths(snapshot: &Snapshot) -> crate::error::Result> { + match snapshot { + Snapshot::Index(index) => { + let mut paths: Vec = index + .entries + .keys() + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n == "REUSE.toml") + }) + .cloned() + .collect(); + paths.sort(); + Ok(paths) + } + Snapshot::Worktree { root } => { + use ignore::{WalkBuilder, overrides::OverrideBuilder}; + let mut ob = OverrideBuilder::new(root); + ob.add("!.git/") + .expect("valid built-in skip override for metadata discovery"); + let overrides = ob.build().expect("valid metadata-discovery overrides"); + let walker = WalkBuilder::new(root) + .hidden(false) + .git_ignore(true) + .git_global(true) + .parents(true) + .overrides(overrides) + .follow_links(false) + .build(); + let mut paths = Vec::new(); + for entry in walker { + let entry = match entry { + Ok(e) => e, + Err(e) => { + return Err(crate::error::LicetError::Io(std::io::Error::other( + format!("metadata discovery traversal failed: {e}"), + ))); + } + }; + if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) { + continue; + } + if entry.file_name().to_str().is_none_or(|n| n != "REUSE.toml") { + continue; + } + if let Ok(rel) = entry.path().strip_prefix(root) { + paths.push(rel.to_path_buf()); + } + } + paths.sort(); + Ok(paths) + } + } } /// Outcome of [`write_annotation`]. @@ -188,10 +683,9 @@ fn compile_globs(patterns: Vec) -> Vec { pub enum AnnotationWrite { /// The file's effective `REUSE.toml` license already matched; nothing written. Unchanged, - /// An existing exact-path annotation's license was rewritten in place. - Updated, - /// A new exact-path annotation was appended (covers a previously uncovered file, - /// or overrides a broader glob entry — last match wins per REUSE 3.3). + /// A new exact-path annotation was appended — covering a previously + /// uncovered file, overriding a broader glob entry, or superseding an + /// older exact-path entry. Being last, it wins per REUSE 3.3. Appended, } @@ -202,162 +696,357 @@ impl AnnotationWrite { } } -/// Ensure a `REUSE.toml` annotation covers `rel_path` with `license` (FR-015, FR-003a). +/// One exact-path annotation the caller wants covered by `REUSE.toml`. +pub struct AnnotationRequest<'a> { + pub rel_path: &'a str, + pub license: &'a str, + pub copyrights: &'a [String], +} + +/// Where one annotation patch belongs: the document that wins for the path, +/// the path relative to that document's base, and whether the stanza needs +/// `override` precedence to govern there. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AnnotationDestination { + /// Repo-relative document path (`REUSE.toml`, `sub/REUSE.toml`). + pub doc_rel: PathBuf, + /// Request path relative to the document's base. + pub base_path: String, + /// True when only an `override` stanza governs at the destination: under + /// an `override` barrier, or when an `aggregate` table contributes + /// unconditionally (a default-`closest` stanza would neither break the + /// barrier nor silence the aggregate). + pub use_override: bool, +} + +/// Route an annotation to its winning document (FR-003a). /// -/// Creates the file with a `version = 1` header if absent. The behavior matches REUSE 3.3 -/// last-match semantics and is idempotent: -/// - if the annotation that currently wins for this file already declares `license`, it is -/// left untouched ([`AnnotationWrite::Unchanged`]); -/// - if the winning annotation is an exact single-path block for this file, its -/// `SPDX-License-Identifier` is rewritten in place ([`AnnotationWrite::Updated`]) — -/// copyright lines are preserved (FR-009); -/// - otherwise (uncovered, or covered only by a broader glob) a new exact-path block is -/// appended ([`AnnotationWrite::Appended`]); being last, it wins. +/// The walk mirrors [`OutOfBand::lookup`] over the already-loaded hierarchy: +/// - no coverage, or `closest` governing → the nearest consulted document +/// (the project root when nothing consults), default precedence — nearest +/// wins the per-field `closest` fallback; +/// - `override` or `aggregate` governing → the project root with `override` +/// precedence — root is consulted first, so the appended stanza breaks +/// before every deeper table and supersedes root tables by last match. +pub fn annotation_destination(oob: &OutOfBand, rel_path: &str) -> AnnotationDestination { + let mut consulted: Vec<(usize, Precedence)> = Vec::new(); + for (di, doc) in oob.docs.iter().enumerate() { + let Some(remainder) = under_base(rel_path, &doc.base) else { + continue; + }; + if let Some(table) = doc + .tables + .iter() + .rev() + .find(|t| matches_any(&t.matchers, remainder)) + { + let is_override = table.precedence == Precedence::Override; + consulted.push((di, table.precedence)); + if is_override { + break; + } + } + } + let governing = if consulted.iter().any(|(_, p)| *p == Precedence::Override) { + Some(Precedence::Override) + } else if consulted.iter().any(|(_, p)| *p == Precedence::Aggregate) { + Some(Precedence::Aggregate) + } else if consulted.is_empty() { + None + } else { + Some(Precedence::Closest) + }; + match governing { + Some(Precedence::Override) | Some(Precedence::Aggregate) => AnnotationDestination { + doc_rel: PathBuf::from("REUSE.toml"), + base_path: rel_path.to_string(), + use_override: true, + }, + Some(Precedence::Closest) => { + let (di, _) = consulted.last().copied().expect("consulted non-empty"); + let doc = &oob.docs[di]; + AnnotationDestination { + doc_rel: doc.rel.clone(), + base_path: under_base(rel_path, &doc.base) + .unwrap_or(rel_path) + .to_string(), + use_override: false, + } + } + None => AnnotationDestination { + doc_rel: PathBuf::from("REUSE.toml"), + base_path: rel_path.to_string(), + use_override: false, + }, + } +} + +/// Ensure a `REUSE.toml` annotation covers `rel_path` with `license` (FR-015, FR-003a). /// -/// The whole file is rewritten atomically (FR-024). +/// Single-request form of [`write_annotations`]; `oob` is the scan's loaded +/// hierarchy, used for coverage and destination routing. pub fn write_annotation( root: &Path, rel_path: &str, license: &str, copyrights: &[String], -) -> std::io::Result { - let reuse_toml = root.join("REUSE.toml"); - let existing = std::fs::read_to_string(&reuse_toml).unwrap_or_default(); - let lines: Vec<&str> = existing.lines().collect(); - let blocks = parse_blocks(&lines); - - // The annotation that currently determines this file's license is the *last* one whose - // path globs match it (REUSE 3.3 overlap resolution). - let winner = blocks - .iter() - .rev() - .find(|b| b.paths.iter().any(|p| glob_matches(p, rel_path))); - - if let Some(b) = winner { - if b.license.as_deref() == Some(license) { - return Ok(AnnotationWrite::Unchanged); - } - // Rewrite in place only when the winner is an exact single-path block for this - // file carrying a license line — it still wins afterward, so this is idempotent. - if b.paths.len() == 1 - && b.paths[0] == rel_path - && let Some(li) = b.license_line - { - let mut new_lines: Vec = lines.iter().map(|s| s.to_string()).collect(); - new_lines[li] = format!("SPDX-License-Identifier = {}", toml_string(license)); - let mut out = new_lines.join("\n"); - if existing.ends_with('\n') { - out.push('\n'); - } - crate::reuse::atomic_write(&reuse_toml, &out)?; - return Ok(AnnotationWrite::Updated); - } - // Glob/array/license-less winner: fall through and append an exact override. - } - - let mut out = existing.clone(); - if out.is_empty() { - out.push_str("version = 1\n\n"); - } else if !out.ends_with('\n') { - out.push('\n'); - } - out.push_str("[[annotations]]\n"); - out.push_str(&format!("path = {}\n", toml_string(rel_path))); - for c in copyrights { - out.push_str(&format!("SPDX-FileCopyrightText = {}\n", toml_string(c))); - } - out.push_str(&format!( - "SPDX-License-Identifier = {}\n\n", - toml_string(license) - )); + oob: &OutOfBand, +) -> Result { + let outcomes = write_annotations( + root, + &[AnnotationRequest { + rel_path, + license, + copyrights, + }], + oob, + )?; + Ok(outcomes + .per_request + .into_iter() + .next() + .unwrap_or(AnnotationWrite::Unchanged)) +} - crate::reuse::atomic_write(&reuse_toml, &out)?; - Ok(AnnotationWrite::Appended) +/// One patched metadata document: the before/after bytes plus which caller +/// requests its stanzas carry, so the caller files one write record per +/// document with the covered assets attached. +#[derive(Debug, Clone)] +pub struct DocPatch { + pub doc_rel: PathBuf, + pub before: Option>, + pub after: Vec, + pub requests: Vec, } -/// A parsed `[[annotations]]` block: its `path` values, current license, and the source -/// line index of the `SPDX-License-Identifier` (for in-place rewrites). -struct AnnBlock { - paths: Vec, - license: Option, - license_line: Option, +/// Outcome of [`write_annotations`]: per-request dispositions plus the +/// document patches actually committed (one per touched document). +#[derive(Debug, Clone)] +pub struct AnnotationBatchOutcome { + pub per_request: Vec, + pub patches: Vec, } -/// Light line-oriented parser for the `[[annotations]]` blocks of a `REUSE.toml`. It only -/// needs `path` and `SPDX-License-Identifier`; anything else is ignored. -fn parse_blocks(lines: &[&str]) -> Vec { - let mut blocks = Vec::new(); - let mut i = 0; - while i < lines.len() { - if lines[i].trim() != "[[annotations]]" { - i += 1; +/// Ensure `REUSE.toml` annotations cover every request (FR-015, FR-003a). +/// +/// `oob` is the scan's loaded hierarchy: coverage and destination routing read +/// it, so decisions match what detection sees. Each request whose effective +/// license *and* copyrights already match is left untouched +/// ([`AnnotationWrite::Unchanged`]); every other request gains one appended +/// exact-path stanza ([`AnnotationWrite::Appended`]) in its winning document +/// ([`annotation_destination`]). Creates the project root document with a +/// `version = 1` header if absent; never creates deeper documents. +/// +/// The patch is strictly append-only: existing documents are never reparsed +/// with a lossy line scan and never reformatted, so comments, ordering, and +/// unrelated stanzas survive byte-for-byte, and a repeated run converges. +/// Requests group by destination document, so each document is read, patched, +/// and written exactly once. Every patched document is re-parsed — and every +/// appended coverage verified — before anything is committed. Copyrights +/// serialize as one string/list field, never repeated keys; exact paths escape +/// REUSE glob metacharacters so odd filenames cannot become new patterns; +/// appended stanzas reuse their document's own newline convention. +/// +/// An unreadable or non-UTF-8 document is an error, never silently replaced +/// with fresh content (F04). +pub fn write_annotations( + root: &Path, + requests: &[AnnotationRequest<'_>], + oob: &OutOfBand, +) -> Result { + use crate::reuse::{WriteError, atomic_write, read_expected_for_write}; + use std::collections::BTreeMap; + + let mut per_request = vec![AnnotationWrite::Unchanged; requests.len()]; + // Requests needing a stanza, grouped by destination document so each + // document is read, patched, and written exactly once. + let mut pending: BTreeMap> = BTreeMap::new(); + for (i, req) in requests.iter().enumerate() { + let entry = oob.lookup(Path::new(req.rel_path)); + let covered = entry.as_ref().and_then(|e| { + e.license() + .or_else(|| crate::domain::combine_licenses(&e.fallback_licenses)) + }); + let copyrights_match = entry.as_ref().is_some_and(|e| { + norm_set(effective_copyrights(e)) == norm_set(req.copyrights.to_vec()) + }); + if covered.as_deref() == Some(req.license) && copyrights_match { continue; } - let mut paths = Vec::new(); - let mut license = None; - let mut license_line = None; - let mut j = i + 1; - while j < lines.len() && lines[j].trim() != "[[annotations]]" { - if let Some((key, val)) = lines[j].split_once('=') { - match key.trim() { - "path" => paths = quoted_values(val), - "SPDX-License-Identifier" => { - license = quoted_values(val).into_iter().next(); - license_line = Some(j); - } - _ => {} + let dest = annotation_destination(oob, req.rel_path); + pending + .entry(dest.doc_rel.clone()) + .or_default() + .push((i, dest)); + } + if pending.is_empty() { + return Ok(AnnotationBatchOutcome { + per_request, + patches: Vec::new(), + }); + } + + let mut patches: Vec = Vec::new(); + + for (doc_rel, jobs) in &pending { + let existing_bytes = + read_expected_for_write(&root.join(doc_rel)).map_err(WriteError::for_read_failure)?; + let existing = match &existing_bytes { + Some(bytes) => std::str::from_utf8(bytes).map_err(|e| { + WriteError::for_read_failure(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("existing {} is not valid UTF-8: {e}", doc_rel.display()), + )) + })?, + // Only the project root document may be created; a missing deeper + // document means the hierarchy moved under us — refuse, don't invent. + None if doc_rel == Path::new("REUSE.toml") => "", + None => { + return Err(WriteError::for_read_failure(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!( + "annotated document {} disappeared during the scan", + doc_rel.display() + ), + ))); + } + }; + + let nl = if existing.contains("\r\n") { + "\r\n" + } else { + "\n" + }; + let mut out = existing.to_owned(); + if out.is_empty() { + out.push_str("version = 1"); + out.push_str(nl); + out.push_str(nl); + } else if !out.ends_with('\n') { + out.push_str(nl); + } + for (i, dest) in jobs { + let req = &requests[*i]; + out.push_str("[[annotations]]"); + out.push_str(nl); + out.push_str(&format!( + "path = {}", + toml_string(&escape_reuse_path(&dest.base_path)) + )); + out.push_str(nl); + if dest.use_override { + out.push_str("precedence = \"override\""); + out.push_str(nl); + } + match req.copyrights.len() { + 0 => {} + 1 => { + out.push_str(&format!( + "SPDX-FileCopyrightText = {}", + toml_string(&req.copyrights[0]) + )); + out.push_str(nl); + } + _ => { + let list = req + .copyrights + .iter() + .map(|s| toml_string(s)) + .collect::>() + .join(", "); + out.push_str(&format!("SPDX-FileCopyrightText = [{list}]")); + out.push_str(nl); } } - j += 1; + out.push_str(&format!( + "SPDX-License-Identifier = {}", + toml_string(req.license) + )); + out.push_str(nl); + out.push_str(nl); + per_request[*i] = AnnotationWrite::Appended; } - blocks.push(AnnBlock { - paths, - license, - license_line, + + // Re-parse the complete proposed document before committing, and + // verify every appended coverage through the same lookup detection + // uses (this also validates the escaping and list serialization). + let mut verify = OutOfBand::default(); + verify.parse_reuse_toml(doc_rel, &out).map_err(|e| { + WriteError::for_read_failure(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("proposed {} failed to re-parse: {e}", doc_rel.display()), + )) + })?; + for (i, _) in jobs { + let req = &requests[*i]; + // Verify through the document's own base, exactly as detection + // will read it back. + let got = verify.lookup(Path::new(req.rel_path)).and_then(|e| { + e.license() + .or_else(|| crate::domain::combine_licenses(&e.fallback_licenses)) + }); + if got.as_deref() != Some(req.license) { + return Err(WriteError::for_read_failure(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "proposed {} does not cover {} with {}", + doc_rel.display(), + req.rel_path, + req.license + ), + ))); + } + } + + atomic_write(root, doc_rel, existing_bytes.as_deref(), out.as_bytes())?; + patches.push(DocPatch { + doc_rel: doc_rel.clone(), + before: existing_bytes.clone(), + after: out.into_bytes(), + requests: jobs.iter().map(|(i, _)| *i).collect(), }); - i = j; } - blocks + Ok(AnnotationBatchOutcome { + per_request, + patches, + }) } -/// Match a `REUSE.toml` path glob against a repo-relative path, falling back to exact -/// equality if the pattern is not a valid glob. -fn glob_matches(pattern: &str, rel_path: &str) -> bool { - match Glob::new(pattern) { - Ok(g) => g.compile_matcher().is_match(rel_path), - Err(_) => pattern == rel_path, +/// Effective OOB copyrights for a lookup entry, mirroring the license logic: +/// unconditional contributors first, else the `closest` fallback. +fn effective_copyrights(entry: &crate::domain::OutOfBandEntry) -> Vec { + if entry.copyrights.is_empty() { + entry.fallback_copyrights.clone() + } else { + entry.copyrights.clone() } } -/// Extract the double-quoted string values from a TOML scalar or inline-array tail, -/// unescaping `\\` and `\"` (sufficient for the small value space REUSE.toml uses here). -fn quoted_values(s: &str) -> Vec { - let mut out = Vec::new(); - let mut cur = String::new(); - let mut in_str = false; - let mut esc = false; - for c in s.chars() { - if !in_str { - if c == '"' { - in_str = true; - } - continue; - } - if esc { - cur.push(c); - esc = false; - } else if c == '\\' { - esc = true; - } else if c == '"' { - out.push(std::mem::take(&mut cur)); - in_str = false; - } else { - cur.push(c); +/// Order-insensitive comparison form for notice lists. +fn norm_set(mut values: Vec) -> Vec { + values.sort(); + values.dedup(); + values +} + +/// Escape the REUSE glob metacharacters in an exact path so the emitted stanza +/// cannot act as a pattern: a file named `a*b.png` must not start covering +/// its siblings. Only `\` and `*` are escaped: both engines treat `\`-asterisk +/// and `\\`-backslash as escapes, while `?`, brackets, and braces are literal +/// in both grammars and must stay bare (this crate's matcher and the reference +/// tool's pathspec grammar agree on all of this). +fn escape_reuse_path(path: &str) -> String { + let mut out = String::with_capacity(path.len()); + for c in path.chars() { + if matches!(c, '\\' | '*') { + out.push('\\'); } + out.push(c); } out } +/// Quote a value for emission into a `REUSE.toml` stanza (write-side escaping only). fn toml_string(s: &str) -> String { format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) } @@ -368,25 +1057,106 @@ mod tests { use super::*; use std::path::PathBuf; + fn load_doc(text: &str) -> OutOfBand { + let mut oob = OutOfBand::default(); + oob.parse_reuse_toml(Path::new("REUSE.toml"), text).unwrap(); + oob + } + #[test] fn parses_reuse_toml_glob() { - let mut oob = OutOfBand::default(); - oob.parse_reuse_toml( + // A `closest` table is fallback only: its values land in the fallback + // fields, leaving the unconditional ones empty for detection to fill + // from file-level info first. + let oob = load_doc( "version = 1\n[[annotations]]\npath = \"assets/**\"\n\ SPDX-License-Identifier = \"CC0-1.0\"\nSPDX-FileCopyrightText = \"2026 Acme\"\n", ); let e = oob.lookup(&PathBuf::from("assets/logo.png")).unwrap(); - assert_eq!(e.license.as_deref(), Some("CC0-1.0")); - assert_eq!(e.copyrights, vec!["2026 Acme".to_string()]); + assert!(e.licenses.is_empty()); + assert_eq!(e.fallback_licenses, vec!["CC0-1.0".to_string()]); + assert_eq!(e.fallback_copyrights, vec!["2026 Acme".to_string()]); + assert_eq!(e.precedence, Precedence::Closest); + assert!(!e.suppresses_file); + } + + #[test] + fn license_array_stays_together() { + // Multiple expressions in one table apply together (aggregate here so + // they are unconditional); the presentation form AND-combines them. + let oob = load_doc( + "version = 1\n[[annotations]]\npath = \"a.bin\"\nprecedence = \"aggregate\"\n\ + SPDX-License-Identifier = [\"MIT\", \"Apache-2.0\"]\n", + ); + let e = oob.lookup(&PathBuf::from("a.bin")).unwrap(); + assert_eq!( + e.licenses, + vec!["MIT".to_string(), "Apache-2.0".to_string()] + ); + assert_eq!(e.license().as_deref(), Some("MIT AND Apache-2.0")); } #[test] fn parses_dep5() { let mut oob = OutOfBand::default(); - oob.parse_dep5("Files: img/*\nCopyright: 2026 Acme\nLicense: MIT\n"); + oob.parse_dep5("Files: img/*\nCopyright: 2026 Acme\nLicense: MIT\n") + .unwrap(); let e = oob.lookup(&PathBuf::from("img/x.jpg")).unwrap(); - assert_eq!(e.license.as_deref(), Some("MIT")); + assert_eq!(e.licenses, vec!["MIT".to_string()]); assert_eq!(e.source, OobSource::Dep5); + assert_eq!(e.precedence, Precedence::Aggregate); + } + + #[test] + fn dep5_star_crosses_directories() { + // Unlike REUSE.toml, dep5 `*` matches across `/` (reference behavior). + let mut oob = OutOfBand::default(); + oob.parse_dep5("Files: *.bin\nLicense: MIT\n").unwrap(); + assert!(oob.lookup(&PathBuf::from("sub/b.bin")).is_some()); + } + + #[test] + fn dep5_continuation_lines_unfold() { + let mut oob = OutOfBand::default(); + oob.parse_dep5( + "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\ + \n\ + Files: a.bin\n sub/b.bin\n\ + Copyright: 2026 A\n 2026 B\n .\n\ + License: MIT\n", + ) + .unwrap(); + let e = oob.lookup(&PathBuf::from("sub/b.bin")).unwrap(); + assert_eq!(e.licenses, vec!["MIT".to_string()]); + assert_eq!( + e.copyrights, + vec!["2026 A".to_string(), "2026 B".to_string()] + ); + } + + #[test] + fn dep5_last_paragraph_wins() { + // Two paragraphs covering the same file: the last one governs, exactly + // as the reference tool reports. + let mut oob = OutOfBand::default(); + oob.parse_dep5( + "Files: a.bin\nCopyright: 2026 A\nLicense: MIT\n\nFiles: a.bin\nCopyright: 2026 B\nLicense: Apache-2.0\n", + ) + .unwrap(); + let e = oob.lookup(&PathBuf::from("a.bin")).unwrap(); + assert_eq!(e.licenses, vec!["Apache-2.0".to_string()]); + assert_eq!(e.copyrights, vec!["2026 B".to_string()]); + assert_eq!(e.origins.len(), 1); + assert_eq!(e.origins[0].table_index, 1); + } + + #[test] + fn dep5_field_names_are_case_insensitive() { + let mut oob = OutOfBand::default(); + oob.parse_dep5("files: a.bin\ncopyright: 2026 A\nlicense: MIT\n") + .unwrap(); + let e = oob.lookup(&PathBuf::from("a.bin")).unwrap(); + assert_eq!(e.licenses, vec!["MIT".to_string()]); } #[test] @@ -396,50 +1166,245 @@ mod tests { } #[test] - fn precedence_defaults_to_closest() { + fn malformed_documents_fail_with_location() { + for (name, text) in [ + ( + "bad-toml", + "version = 1\n[[annotations]]\npath = \nSPDX-License-Identifier = \"MIT\"\n", + ), + ("version-2", "version = 2\n[[annotations]]\npath = \"a\"\n"), + ( + "missing-path", + "version = 1\n[[annotations]]\nSPDX-License-Identifier = \"MIT\"\n", + ), + ( + "empty-path", + "version = 1\n[[annotations]]\npath = []\nSPDX-License-Identifier = \"MIT\"\n", + ), + ( + "bad-precedence", + "version = 1\n[[annotations]]\npath = \"a\"\nprecedence = \"bogus\"\n", + ), + ( + "capital-precedence", + "version = 1\n[[annotations]]\npath = \"a\"\nprecedence = \"Closest\"\n", + ), + ( + "bad-expression", + "version = 1\n[[annotations]]\npath = \"a\"\nSPDX-License-Identifier = \"NOT-A-LICENSE\"\n", + ), + ] { + let mut oob = OutOfBand::default(); + let err = oob + .parse_reuse_toml(Path::new("REUSE.toml"), text) + .expect_err(&format!("{name} must fail")); + let msg = err.to_string(); + assert!( + msg.contains("REUSE.toml"), + "{name}: error names the document: {msg}" + ); + } let mut oob = OutOfBand::default(); - oob.parse_reuse_toml("[[annotations]]\npath = \"a\"\nSPDX-License-Identifier = \"MIT\"\n"); + let err = oob + .parse_dep5("Files: a.bin\nLicense: NOT-A-LICENSE\n") + .expect_err("bad dep5 License must fail"); + assert!(err.to_string().contains(".reuse/dep5"), "{err}"); + } + + #[test] + fn missing_version_fails() { + let mut oob = OutOfBand::default(); + let err = oob + .parse_reuse_toml(Path::new("REUSE.toml"), "[[annotations]]\npath = \"a\"\n") + .expect_err("missing version must fail"); + assert!(err.to_string().contains("REUSE.toml"), "{err}"); + } + + #[test] + fn unknown_keys_are_ignored() { + // The REUSE schema explicitly permits extension keys. + let oob = load_doc( + "version = 1\n[tool.extra]\nnote = 1\n[[annotations]]\npath = \"a\"\n\ + SPDX-License-Identifier = \"MIT\"\nSPDX-FileComment = \"hi\"\n", + ); assert_eq!( - oob.lookup(&PathBuf::from("a")).unwrap().precedence, - Precedence::Closest + oob.lookup(&PathBuf::from("a")).unwrap().fallback_licenses, + vec!["MIT".to_string()] ); } #[test] - fn precedence_override_parsed() { + fn nested_documents_resolve_root_first() { + let mut oob = load_doc( + "version = 1\n[[annotations]]\npath = \"sub/f.rs\"\nSPDX-License-Identifier = \"MIT\"\n\ + SPDX-FileCopyrightText = \"2026 Root\"\n", + ); + oob.parse_reuse_toml( + Path::new("sub/REUSE.toml"), + "version = 1\n[[annotations]]\npath = \"f.rs\"\nSPDX-License-Identifier = \"Apache-2.0\"\n", + ) + .unwrap(); + // Per-field nearest-outward fallback: the child's license wins, but + // the child says nothing about copyright so the root still supplies it. + let e = oob.lookup(&PathBuf::from("sub/f.rs")).unwrap(); + assert_eq!(e.fallback_licenses, vec!["Apache-2.0".to_string()]); + assert_eq!(e.fallback_copyrights, vec!["2026 Root".to_string()]); + assert_eq!(e.origins.len(), 2); + assert_eq!( + e.origins[0].metadata_path, + PathBuf::from("REUSE.toml"), + "origins read shallowest-document first" + ); + assert_eq!(e.origins[1].metadata_path, PathBuf::from("sub/REUSE.toml")); + } + + #[test] + fn nested_patterns_cannot_escape_their_directory() { let mut oob = OutOfBand::default(); oob.parse_reuse_toml( - "[[annotations]]\npath = \"a\"\nprecedence = \"override\"\nSPDX-License-Identifier = \"MIT\"\n", + Path::new("sub/REUSE.toml"), + "version = 1\n[[annotations]]\npath = \"**\"\nprecedence = \"aggregate\"\n\ + SPDX-License-Identifier = \"MIT\"\n", + ) + .unwrap(); + assert!(oob.lookup(&PathBuf::from("sub/a.rs")).is_some()); + assert!( + oob.lookup(&PathBuf::from("other.rs")).is_none(), + "a nested document never covers its parent" + ); + assert!( + oob.lookup(&PathBuf::from("REUSE.toml")).is_none(), + "metadata documents are not covered by nested globs" + ); + } + + #[test] + fn override_barrier_suppresses_deeper_tables() { + let mut oob = load_doc( + "version = 1\n[[annotations]]\npath = \"sub/f.rs\"\nprecedence = \"override\"\n\ + SPDX-License-Identifier = \"MIT\"\nSPDX-FileCopyrightText = \"2026 Root\"\n", + ); + oob.parse_reuse_toml( + Path::new("sub/REUSE.toml"), + "version = 1\n[[annotations]]\npath = \"f.rs\"\nprecedence = \"aggregate\"\n\ + SPDX-License-Identifier = \"Apache-2.0\"\n", + ) + .unwrap(); + let e = oob.lookup(&PathBuf::from("sub/f.rs")).unwrap(); + assert!(e.suppresses_file); + assert_eq!(e.precedence, Precedence::Override); + assert_eq!(e.licenses, vec!["MIT".to_string()]); + assert!( + !e.licenses.contains(&"Apache-2.0".to_string()), + "the deeper aggregate is suppressed by the rootmost override" + ); + } + + #[test] + fn empty_override_still_suppresses() { + // A field-less override table contributes nothing but still + // suppresses file-level info (reference behavior). + let oob = + load_doc("version = 1\n[[annotations]]\npath = \"a.bin\"\nprecedence = \"override\"\n"); + let e = oob.lookup(&PathBuf::from("a.bin")).unwrap(); + assert!(e.suppresses_file); + assert!(e.licenses.is_empty() && e.copyrights.is_empty()); + } + + #[test] + fn reuse_pattern_grammar() { + // `*` never crosses `/`; `**` does; `?[]{} ` are literal; `\` escapes. + let oob = load_doc( + "version = 1\n\ + [[annotations]]\npath = \"*.png\"\nprecedence = \"aggregate\"\nSPDX-License-Identifier = \"MIT\"\n\ + [[annotations]]\npath = \"a?.png\"\nprecedence = \"aggregate\"\nSPDX-License-Identifier = \"Apache-2.0\"\n\ + [[annotations]]\npath = \"deep/**\"\nprecedence = \"aggregate\"\nSPDX-License-Identifier = \"CC0-1.0\"\n", ); + // Last match wins within the document. assert_eq!( - oob.lookup(&PathBuf::from("a")).unwrap().precedence, - Precedence::Override + oob.lookup(&PathBuf::from("a?.png")).unwrap().licenses, + vec!["Apache-2.0".to_string()], + "`?` is literal: the exact file matches the literal pattern" + ); + // `*.png` must not have matched `a?.png` via wildcard, or last-match + // would still hold — check a file only `*` can match instead. + assert!( + oob.lookup(&PathBuf::from("sub/a.png")).is_none(), + "`*` must not cross `/`" + ); + assert_eq!( + oob.lookup(&PathBuf::from("deep/nest/a.png")) + .unwrap() + .licenses, + vec!["CC0-1.0".to_string()], + "`**` crosses directories" ); } #[test] - fn dep5_is_aggregate_precedence() { + fn reuse_escapes_match_verbatim() { let mut oob = OutOfBand::default(); - oob.parse_dep5("Files: img/*\nLicense: MIT\n"); - assert_eq!( - oob.lookup(&PathBuf::from("img/x")).unwrap().precedence, - Precedence::Aggregate + // TOML `"star\\\\*.bin"` is the pattern `star\*.bin`: a literal star. + oob.parse_reuse_toml( + Path::new("REUSE.toml"), + "version = 1\n[[annotations]]\npath = \"star\\\\*.bin\"\nprecedence = \"aggregate\"\n\ + SPDX-License-Identifier = \"MIT\"\n", + ) + .unwrap(); + assert!(oob.lookup(&PathBuf::from("star*.bin")).is_some()); + assert!(oob.lookup(&PathBuf::from("starX.bin")).is_none()); + } + + #[test] + fn braces_and_brackets_are_literal() { + let oob = load_doc( + "version = 1\n[[annotations]]\npath = \"a[0].{png,bin}\"\nprecedence = \"aggregate\"\n\ + SPDX-License-Identifier = \"MIT\"\n", + ); + assert!(oob.lookup(&PathBuf::from("a[0].{png,bin}")).is_some()); + assert!(oob.lookup(&PathBuf::from("a0.png")).is_none()); + } + + #[test] + fn coexistence_with_dep5_fails() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("REUSE.toml"), + "version = 1\n[[annotations]]\npath = \"a\"\nSPDX-License-Identifier = \"MIT\"\n", + ) + .unwrap(); + std::fs::create_dir(dir.path().join(".reuse")).unwrap(); + std::fs::write( + dir.path().join(".reuse/dep5"), + "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n", + ) + .unwrap(); + let err = OutOfBand::load(dir.path()).expect_err("coexistence must fail"); + let msg = err.to_string(); + assert!( + msg.contains("REUSE.toml") && msg.contains(".reuse/dep5"), + "{msg}" ); } + /// The scan's hierarchy as the write probe sees it. + fn load_root(root: &std::path::Path) -> OutOfBand { + OutOfBand::load(root).unwrap() + } + #[test] fn write_annotation_is_idempotent() { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let cprs = vec!["2026 Acme".to_string()]; assert_eq!( - write_annotation(root, "logo.png", "CC0-1.0", &cprs).unwrap(), + write_annotation(root, "logo.png", "CC0-1.0", &cprs, &load_root(root)).unwrap(), AnnotationWrite::Appended ); let first = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); // Second write of the same path with the same license is a no-op. assert_eq!( - write_annotation(root, "logo.png", "CC0-1.0", &cprs).unwrap(), + write_annotation(root, "logo.png", "CC0-1.0", &cprs, &load_root(root)).unwrap(), AnnotationWrite::Unchanged ); let second = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); @@ -449,30 +1414,53 @@ mod tests { } #[test] - fn write_annotation_updates_exact_path_in_place() { + fn write_annotation_appends_superseding_stanza_for_same_path() { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let cprs = vec!["2026 Acme".to_string()]; - write_annotation(root, "logo.png", "CC0-1.0", &cprs).unwrap(); - // A new intent for the same exact path rewrites the license line in place. + write_annotation(root, "logo.png", "CC0-1.0", &cprs, &load_root(root)).unwrap(); + // A new intent for the same exact path appends a superseding stanza; + // the old one is left byte-for-byte (last match wins per REUSE 3.3). assert_eq!( - write_annotation(root, "logo.png", "CC-BY-4.0", &cprs).unwrap(), - AnnotationWrite::Updated + write_annotation(root, "logo.png", "CC-BY-4.0", &cprs, &load_root(root)).unwrap(), + AnnotationWrite::Appended ); let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); - assert_eq!(text.matches("path = \"logo.png\"").count(), 1); + assert_eq!(text.matches("path = \"logo.png\"").count(), 2); + assert!(text.contains("SPDX-License-Identifier = \"CC0-1.0\"")); assert!(text.contains("SPDX-License-Identifier = \"CC-BY-4.0\"")); - assert!(!text.contains("CC0-1.0")); - // Copyright is preserved across the in-place license change (FR-009). - assert!(text.contains("SPDX-FileCopyrightText = \"2026 Acme\"")); - // And it round-trips through lookup at the new license. + // The appended stanza wins through lookup (a `closest` table surfaces + // as the fallback until file-level info exists) … let mut oob = OutOfBand::default(); - oob.parse_reuse_toml(&text); + oob.parse_reuse_toml(Path::new("REUSE.toml"), &text) + .unwrap(); assert_eq!( - oob.lookup(&PathBuf::from("logo.png")).unwrap().license, - Some("CC-BY-4.0".to_string()) + oob.lookup(&PathBuf::from("logo.png")) + .unwrap() + .fallback_licenses, + vec!["CC-BY-4.0".to_string()] ); + // … and the rerun converges to a no-op. + assert_eq!( + write_annotation(root, "logo.png", "CC-BY-4.0", &cprs, &load_root(root)).unwrap(), + AnnotationWrite::Unchanged + ); + } + + #[test] + fn write_annotation_refuses_malformed_document() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("REUSE.toml"), "[[annotations\npath = \n").unwrap(); + let before = std::fs::read(root.join("REUSE.toml")).unwrap(); + // The probe hierarchy is empty (the scan would already have failed on + // this document); the re-parse gate still refuses to append to it. + let err = write_annotation(root, "logo.png", "CC0-1.0", &[], &OutOfBand::default()) + .expect_err("malformed REUSE.toml must fail closed"); + assert!(err.to_string().contains("re-parse"), "{err}"); + // The broken document is left untouched — nothing appended. + assert_eq!(std::fs::read(root.join("REUSE.toml")).unwrap(), before); } #[test] @@ -488,34 +1476,352 @@ mod tests { // A glob covers the file; we cannot edit it without affecting siblings, so we // append a more-specific exact-path block. Last match wins. assert_eq!( - write_annotation(root, "logo.png", "CC-BY-4.0", &[]).unwrap(), + write_annotation(root, "logo.png", "CC-BY-4.0", &[], &load_root(root)).unwrap(), AnnotationWrite::Appended ); let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); let mut oob = OutOfBand::default(); - oob.parse_reuse_toml(&text); + oob.parse_reuse_toml(Path::new("REUSE.toml"), &text) + .unwrap(); + // Both tables are `closest` fallbacks without file-level info, so the + // last match governs each file. assert_eq!( - oob.lookup(&PathBuf::from("logo.png")).unwrap().license, - Some("CC-BY-4.0".to_string()), + oob.lookup(&PathBuf::from("logo.png")) + .unwrap() + .fallback_licenses, + vec!["CC-BY-4.0".to_string()], "exact override must win over the glob" ); assert_eq!( - oob.lookup(&PathBuf::from("other.png")).unwrap().license, - Some("MIT".to_string()), + oob.lookup(&PathBuf::from("other.png")) + .unwrap() + .fallback_licenses, + vec!["MIT".to_string()], "the glob still governs its other files" ); } + #[test] + fn write_annotations_batch_many_files_single_doc_write() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let empty: Vec = vec![]; + let acme = vec!["2026 Acme".to_string()]; + let outcome = write_annotations( + root, + &[ + AnnotationRequest { + rel_path: "a.png", + license: "CC0-1.0", + copyrights: &empty, + }, + AnnotationRequest { + rel_path: "b.png", + license: "MIT", + copyrights: &acme, + }, + ], + &load_root(root), + ) + .unwrap(); + assert_eq!( + outcome.per_request, + vec![AnnotationWrite::Appended, AnnotationWrite::Appended] + ); + // One document touched, one patch carrying both requests. + assert_eq!(outcome.patches.len(), 1); + assert_eq!(outcome.patches[0].doc_rel, PathBuf::from("REUSE.toml")); + assert_eq!(outcome.patches[0].requests, vec![0, 1]); + let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); + assert_eq!(text.matches("[[annotations]]").count(), 2); + // Both resolve, and a rerun over the same batch converges entirely. + let oob = load_root(root); + assert_eq!( + oob.lookup(&PathBuf::from("a.png")) + .unwrap() + .fallback_licenses, + vec!["CC0-1.0".to_string()] + ); + assert_eq!( + oob.lookup(&PathBuf::from("b.png")) + .unwrap() + .fallback_licenses, + vec!["MIT".to_string()] + ); + let rerun = write_annotations( + root, + &[ + AnnotationRequest { + rel_path: "a.png", + license: "CC0-1.0", + copyrights: &empty, + }, + AnnotationRequest { + rel_path: "b.png", + license: "MIT", + copyrights: &acme, + }, + ], + &oob, + ) + .unwrap(); + assert_eq!( + rerun.per_request, + vec![AnnotationWrite::Unchanged, AnnotationWrite::Unchanged] + ); + assert!(rerun.patches.is_empty()); + assert_eq!( + std::fs::read_to_string(root.join("REUSE.toml")).unwrap(), + text + ); + } + + #[test] + fn multiple_copyrights_serialize_as_list() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let cprs = vec!["2026 Acme".to_string(), "2027 Acme".to_string()]; + assert_eq!( + write_annotation(root, "logo.png", "CC0-1.0", &cprs, &load_root(root)).unwrap(), + AnnotationWrite::Appended + ); + let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); + // One string/list field, never repeated keys (repeated keys are + // invalid TOML and would fail the re-parse gate). + assert_eq!(text.matches("SPDX-FileCopyrightText").count(), 1, "{text}"); + assert!( + text.contains("SPDX-FileCopyrightText = [\"2026 Acme\", \"2027 Acme\"]"), + "{text}" + ); + let oob = load_root(root); + assert_eq!( + oob.lookup(&PathBuf::from("logo.png")) + .unwrap() + .fallback_copyrights, + cprs + ); + } + + #[test] + fn glob_metachar_path_is_escaped_to_literal() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + assert_eq!( + write_annotation(root, "a*b.png", "CC0-1.0", &[], &load_root(root)).unwrap(), + AnnotationWrite::Appended + ); + let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); + // Two layers of escaping: TOML decodes `\\` to `\`, leaving the + // REUSE-level `\`-asterisk escape the matcher (and the reference + // tool) reads as a literal star. + assert!(text.contains("path = \"a\\\\*b.png\""), "{text}"); + let oob = load_root(root); + assert!(oob.lookup(&PathBuf::from("a*b.png")).is_some()); + assert!( + oob.lookup(&PathBuf::from("aXb.png")).is_none(), + "escaped path must not act as a glob" + ); + } + + #[test] + fn crlf_document_keeps_crlf_on_append() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("REUSE.toml"), + "version = 1\r\n\r\n[[annotations]]\r\npath = \"a.png\"\r\n\ + SPDX-License-Identifier = \"MIT\"\r\n", + ) + .unwrap(); + assert_eq!( + write_annotation(root, "b.png", "CC0-1.0", &[], &load_root(root)).unwrap(), + AnnotationWrite::Appended + ); + let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); + assert!(!text.contains('\n') || text.contains("\r\n"), "{text:?}"); + assert!( + text.contains("[[annotations]]\r\npath = \"b.png\"\r\n"), + "{text:?}" + ); + // Still parses: the appended stanza reuses the document convention. + load_root(root) + .lookup(&PathBuf::from("b.png")) + .expect("b.png covered"); + } + + #[test] + fn two_holder_broad_annotation_gets_exact_exception() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let before = "version = 1\n\n[[annotations]]\npath = [\"a.png\", \"b.png\"]\n\ + SPDX-FileCopyrightText = [\"2026 Acme\", \"2027 Acme\"]\n\ + SPDX-License-Identifier = \"MIT\"\n"; + std::fs::write(root.join("REUSE.toml"), before).unwrap(); + // Narrowing a.png must not rewrite the shared stanza: append an exact + // exception carrying both holders. + assert_eq!( + write_annotation( + root, + "a.png", + "CC-BY-4.0", + &["2026 Acme".to_string(), "2027 Acme".to_string()], + &load_root(root), + ) + .unwrap(), + AnnotationWrite::Appended + ); + let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); + assert!( + text.starts_with(before), + "shared stanza byte-identical: {text}" + ); + let oob = load_root(root); + assert_eq!( + oob.lookup(&PathBuf::from("a.png")) + .unwrap() + .fallback_licenses, + vec!["CC-BY-4.0".to_string()] + ); + assert_eq!( + oob.lookup(&PathBuf::from("a.png")) + .unwrap() + .fallback_copyrights, + vec!["2026 Acme".to_string(), "2027 Acme".to_string()] + ); + // The sibling still rides the broad annotation untouched. + assert_eq!( + oob.lookup(&PathBuf::from("b.png")) + .unwrap() + .fallback_licenses, + vec!["MIT".to_string()] + ); + } + + #[test] + fn matching_license_missing_copyright_appends() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("REUSE.toml"), + "version = 1\n\n[[annotations]]\npath = \"a.png\"\n\ + SPDX-License-Identifier = \"MIT\"\n", + ) + .unwrap(); + // Same license but a new requested notice is a real change, not Unchanged. + assert_eq!( + write_annotation( + root, + "a.png", + "MIT", + &["2026 Acme".to_string()], + &load_root(root), + ) + .unwrap(), + AnnotationWrite::Appended + ); + let oob = load_root(root); + assert_eq!( + oob.lookup(&PathBuf::from("a.png")) + .unwrap() + .fallback_copyrights, + vec!["2026 Acme".to_string()] + ); + } + + #[test] + fn unknown_keys_comments_and_literal_path_survive_append() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let before = "# Hand-authored header comment.\nversion = 1\ncustom = 42\n\n\ + [[annotations]]\npath = 'a.png'\n# inline comment\nunknown-key = true\n\ + SPDX-License-Identifier = \"MIT\"\n"; + std::fs::write(root.join("REUSE.toml"), before).unwrap(); + assert_eq!( + write_annotation(root, "b.png", "CC0-1.0", &[], &load_root(root)).unwrap(), + AnnotationWrite::Appended + ); + let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); + assert!( + text.starts_with(before), + "hand-authored content byte-identical: {text}" + ); + } + + #[test] + fn subdir_closest_routes_to_subdir_doc_with_base_path() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir(root.join("sub")).unwrap(); + std::fs::write( + root.join("sub/REUSE.toml"), + "version = 1\n\n[[annotations]]\npath = \"*.png\"\n\ + SPDX-License-Identifier = \"MIT\"\n", + ) + .unwrap(); + // The subdir document wins the `closest` fallback for its tree, so the + // exact exception lands there with a base-relative path — a root + // stanza would lose to the nearer document. + let dest = annotation_destination(&load_root(root), "sub/logo.png"); + assert_eq!(dest.doc_rel, PathBuf::from("sub/REUSE.toml")); + assert_eq!(dest.base_path, "logo.png"); + assert!(!dest.use_override); + assert_eq!( + write_annotation(root, "sub/logo.png", "CC-BY-4.0", &[], &load_root(root)).unwrap(), + AnnotationWrite::Appended + ); + assert!( + !root.join("REUSE.toml").exists(), + "nothing appended at the root" + ); + let text = std::fs::read_to_string(root.join("sub/REUSE.toml")).unwrap(); + assert!(text.contains("path = \"logo.png\""), "{text}"); + let oob = load_root(root); + assert_eq!( + oob.lookup(&PathBuf::from("sub/logo.png")) + .unwrap() + .fallback_licenses, + vec!["CC-BY-4.0".to_string()] + ); + } + + #[test] + fn subdir_override_barrier_gets_root_override_stanza() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir(root.join("sub")).unwrap(); + std::fs::write( + root.join("sub/REUSE.toml"), + "version = 1\n\n[[annotations]]\npath = \"logo.png\"\nprecedence = \"override\"\n\ + SPDX-License-Identifier = \"MIT\"\n", + ) + .unwrap(); + // A nearer `override` barrier can only be superseded from the root: + // root is consulted first, so a root override stanza breaks before the + // subdir document is ever read. + let dest = annotation_destination(&load_root(root), "sub/logo.png"); + assert_eq!(dest.doc_rel, PathBuf::from("REUSE.toml")); + assert!(dest.use_override); + assert_eq!( + write_annotation(root, "sub/logo.png", "CC-BY-4.0", &[], &load_root(root)).unwrap(), + AnnotationWrite::Appended + ); + let oob = load_root(root); + let entry = oob.lookup(&PathBuf::from("sub/logo.png")).unwrap(); + assert_eq!(entry.license().as_deref(), Some("CC-BY-4.0")); + assert!(entry.suppresses_file); + } + #[test] fn lookup_is_last_match() { - let mut oob = OutOfBand::default(); - oob.parse_reuse_toml( - "[[annotations]]\npath = \"*.png\"\nSPDX-License-Identifier = \"MIT\"\n\ + let oob = load_doc( + "version = 1\n[[annotations]]\npath = \"*.png\"\nSPDX-License-Identifier = \"MIT\"\n\ [[annotations]]\npath = \"logo.png\"\nSPDX-License-Identifier = \"CC-BY-4.0\"\n", ); assert_eq!( - oob.lookup(&PathBuf::from("logo.png")).unwrap().license, - Some("CC-BY-4.0".to_string()) + oob.lookup(&PathBuf::from("logo.png")) + .unwrap() + .fallback_licenses, + vec!["CC-BY-4.0".to_string()] ); } } diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 8ee20ce..9a477e5 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -10,7 +10,6 @@ use globset::{Glob, GlobMatcher}; use crate::config::{LicensingConfiguration, Rule}; use crate::domain::{LicenseIntent, RuleConflict, Selector}; -use crate::spdx; /// Precompiled rule set for fast repeated matching. pub struct RuleSet<'a> { @@ -76,19 +75,18 @@ impl<'a> RuleSet<'a> { // Deterministic order by declaration position. matches.sort_by_key(|r| r.source_order); - // Among equal-specificity matches, differing licenses are a conflict. + // Among equal-specificity matches, differing full intent (license or + // copyright policy) is a conflict; identical intent resolves to the + // earliest declaration order. let first = matches[0]; - let differing = matches.iter().any(|r| { - !spdx::expressions_equal( - &r.intent.license_expression, - &first.intent.license_expression, - ) - }); + let differing = matches + .iter() + .any(|r| !crate::domain::intents_equal(&r.intent, &first.intent)); if matches.len() > 1 && differing { let labels: Vec = matches.iter().map(|r| r.label()).collect(); return Match::Conflict(RuleConflict { message: format!( - "{} rules of equal specificity match with differing licenses: {}", + "{} rules of equal specificity match with differing intent: {}", matches.len(), labels.join(", ") ), @@ -100,6 +98,29 @@ impl<'a> RuleSet<'a> { } } +/// Every rule whose selector matches `rel_path`, most specific first (ties by +/// earliest declaration). Powers `--explain` losing-rule reporting; the +/// winning pick itself stays in [`RuleSet::resolve`]. +pub fn matching_rules<'a>( + config: &'a LicensingConfiguration, + rel_path: &Path, +) -> Vec<(&'a Rule, u32)> { + let mut out: Vec<(&'a Rule, u32)> = config + .rules + .iter() + .filter(|rule| { + let glob = match &rule.selector { + Selector::Glob(pat) => Glob::new(pat).ok().map(|g| g.compile_matcher()), + _ => None, + }; + selector_matches(&rule.selector, glob.as_ref(), rel_path) + }) + .map(|rule| (rule, rule.selector.specificity())) + .collect(); + out.sort_by_key(|(rule, spec)| (u32::MAX - spec, rule.source_order)); + out +} + /// Does a single selector match a repo-relative path? fn selector_matches(selector: &Selector, glob: Option<&GlobMatcher>, rel_path: &Path) -> bool { match selector { @@ -183,4 +204,32 @@ mod tests { Match::Rule(_) )); } + + #[test] + fn equal_specificity_same_license_different_copyright_conflicts() { + // Full intent (license + copyright policy) decides: identical + // licenses with different copyright handling still conflict. + let c = cfg( + "[[rule]]\nglob=\"examples/**\"\nlicense=\"MIT\"\ncopyright=\"preserve\"\n\ + [[rule]]\nglob=\"**/*.rs\"\nlicense=\"MIT\"\ncopyright=\"add:2026 Acme\"\n", + ); + let rs = RuleSet::new(&c); + assert!(matches!( + rs.resolve(&PathBuf::from("examples/demo.rs")), + Match::Conflict(_) + )); + } + + #[test] + fn equal_specificity_identical_full_intent_resolves() { + let c = cfg( + "[[rule]]\nglob=\"examples/**\"\nlicense=\"MIT\"\ncopyright=\"add:2026 Acme\"\n\ + [[rule]]\nglob=\"**/*.rs\"\nlicense=\"MIT\"\ncopyright=\"add:2026 Acme\"\n", + ); + let rs = RuleSet::new(&c); + assert!(matches!( + rs.resolve(&PathBuf::from("examples/demo.rs")), + Match::Rule(_) + )); + } } diff --git a/src/spdx/mod.rs b/src/spdx/mod.rs index 3fcce80..137f1ee 100644 --- a/src/spdx/mod.rs +++ b/src/spdx/mod.rs @@ -37,207 +37,277 @@ pub fn is_known_id(id: &str) -> bool { /// Validate a full SPDX expression (allowing `LicenseRef-*` and `+`/`WITH`). /// +/// Strict dependency parsing only: sloppy-but-guessable input (`mit`, +/// `apache2`) is rejected here and diagnosed downstream. Case tolerance lives +/// in comparison ([`expressions_equal`]), not acceptance. /// Returns `Ok(())` for a parseable expression, otherwise an explanatory message. pub fn validate_expression(expr: &str) -> Result<(), String> { - parse_canonical(expr).map(|_| ()) + let trimmed = expr.trim(); + if trimmed.is_empty() { + return Err("empty license expression".to_string()); + } + spdx::Expression::parse(trimmed) + .map(|_| ()) + .map_err(|e| format!("invalid SPDX expression `{trimmed}`: {e}")) } /// Parse an expression and render it in a canonical, comparable form. /// /// Canonical form is equal up to commutativity, associativity, whitespace, -/// parenthesization, and case of identifiers (FR-005, research §2). Full -/// distributive equivalence is an explicit non-goal for v1. +/// parenthesization, and case of identifiers (FR-005). Full distributive +/// equivalence is an explicit non-goal for v1. pub fn parse_canonical(expr: &str) -> Result { let trimmed = expr.trim(); if trimmed.is_empty() { return Err("empty license expression".to_string()); } - - // `spdx::Expression` accepts LicenseRef-* and compound expressions, and validates - // identifiers against the bundled SPDX list. - let parsed = spdx::Expression::parse(trimmed) - .map_err(|e| format!("invalid SPDX expression `{trimmed}`: {e}"))?; - - Ok(canonicalize(&parsed)) + parse_expression_ast(trimmed) + .map(|parts| parts.join(" ")) + .ok_or_else(|| format!("invalid SPDX expression `{trimmed}`")) } -/// Semantic equality of two SPDX expressions via canonical form (FR-005). -pub fn expressions_equal(a: &str, b: &str) -> bool { - match (parse_canonical(a), parse_canonical(b)) { - (Ok(ca), Ok(cb)) => ca == cb, - // Fall back to case-insensitive string compare for unparseable inputs so two - // identical raw strings still compare equal. - _ => a.trim().eq_ignore_ascii_case(b.trim()), +/// Every license/exception identifier an expression references, in first-seen +/// order: parsed from the dependency AST via requirement spans (so tabs, +/// newlines, and casing variants split correctly), falling back to +/// whitespace splitting for invalid expressions. +pub fn expression_ids(expr: &str) -> Vec { + if let Ok(parsed) = ::spdx::Expression::parse(expr) { + return ids_from_spans(expr, &parsed); } + let folded = casefold_known_ids(expr); + if folded != expr + && let Ok(parsed) = ::spdx::Expression::parse(&folded) + { + return ids_from_spans(&folded, &parsed); + } + fallback_split_ids(expr) } -/// Render an `spdx::Expression` into a canonical string, flattening associative AND/OR -/// nestings and sorting commutative operands so equivalent expressions render identically. -fn canonicalize(expr: &spdx::Expression) -> String { - normalize_boolean(expr.as_ref()) -} - -/// Boolean AST used purely for canonicalization (associativity + commutativity). -enum Node { - Or(Vec), - And(Vec), - Leaf(String), +/// Read identifiers out of a parsed expression: each requirement span names +/// the license exactly as written (`MIT`, `GPL-2.0+`, `LicenseRef-X`), while +/// a `WITH` exception travels in the requirement's `addition` (the iterator +/// does not yield it separately, so spans alone would drop it). +fn ids_from_spans(source: &str, parsed: &::spdx::Expression) -> Vec { + let mut ids = Vec::new(); + let mut push = |id: &str| { + let id = id.trim().trim_end_matches('+'); + if !id.is_empty() && !ids.contains(&id.to_string()) { + ids.push(id.to_string()); + } + }; + for req in parsed.requirements() { + let text = &source[req.span.start as usize..req.span.end as usize]; + push(text); + if let Some(addition) = &req.req.addition { + push(&addition.to_string()); + } + } + ids } -/// Normalize a (possibly parenthesized) AND/OR expression string into canonical form. -fn normalize_boolean(s: &str) -> String { - render(&parse_node(s)) +/// Best-effort identifier split for expressions that do not parse. +fn fallback_split_ids(expr: &str) -> Vec { + let mut ids = Vec::new(); + for token in expr.split(|c: char| c.is_whitespace() || c == '(' || c == ')') { + let id = token.trim().trim_end_matches('+'); + if id.is_empty() + || id.eq_ignore_ascii_case("or") + || id.eq_ignore_ascii_case("and") + || id.eq_ignore_ascii_case("with") + { + continue; + } + if !ids.contains(&id.to_string()) { + ids.push(id.to_string()); + } + } + ids } -fn parse_node(s: &str) -> Node { - let s = strip_outer_parens(s.trim()); - let ors = split_top_level(s, "OR"); - if ors.len() > 1 { - return Node::Or(ors.iter().map(|x| parse_node(x)).collect()); - } - let ands = split_top_level(s, "AND"); - if ands.len() > 1 { - return Node::And(ands.iter().map(|x| parse_node(x)).collect()); +/// Semantic equality of two SPDX expressions via canonical form (FR-005, F12). +pub fn expressions_equal(a: &str, b: &str) -> bool { + match (parse_expression_ast(a), parse_expression_ast(b)) { + (Some(x), Some(y)) => x == y, + // Preserve raw-string behavior for values the parser rejects: equal only + // when byte-identical after trimming (callers only invoke this on + // validated expressions, so this is unreachable in practice). + _ => a.trim() == b.trim(), } - Node::Leaf(normalize_leaf(s)) } -/// Flatten same-operator children into a single operand list. -fn flatten<'a>(node: &'a Node, want_or: bool, out: &mut Vec<&'a Node>) { - match node { - Node::Or(children) if want_or => children.iter().for_each(|c| flatten(c, true, out)), - Node::And(children) if !want_or => children.iter().for_each(|c| flatten(c, false, out)), - other => out.push(other), - } +/// Parse an expression into canonical sorted-operand form through the dependency +/// AST ([`spdx::Expression::iter`], postfix `Req`/`Op` nodes), so `MIT OR +/// Apache-2.0` and `(Apache-2.0 OR MIT)` compare equal (FR-005, F12). The postfix +/// nodes rebuild a tree on a stack, so grouping comes from the parser — `AND` +/// binds tighter than `OR`, `WITH` binds tightest, and `AND`/`OR`/`WITH` are +/// never confused by string surgery. +/// +/// Strict parsing first; on failure a whole-token case-fold retry supplies the +/// promised standard-id case tolerance (`mit` → `MIT`, `apache-2.0` → +/// `Apache-2.0`) without the dependency's lax prefix/slash/deprecated +/// guessing. Anything still unparseable is `None` and the callers preserve +/// exact-string behavior for it. +fn parse_expression_ast(expression: &str) -> Option> { + parse_ast_strict(expression).or_else(|| parse_ast_strict(&casefold_known_ids(expression))) } -fn render(node: &Node) -> String { - match node { - Node::Leaf(s) => s.clone(), - Node::Or(_) => { - let mut operands = Vec::new(); - flatten(node, true, &mut operands); - // OR is lowest precedence; AND children need no parens, leaves none. - let mut parts: Vec = operands.iter().map(|c| render(c)).collect(); - parts.sort(); - parts.dedup(); - parts.join(" OR ") +/// Lowercase → canonical spelling for every known license/exception id, +/// built once from the dependency's own tables. +fn canonical_id_map() -> &'static std::collections::HashMap { + static MAP: std::sync::OnceLock> = + std::sync::OnceLock::new(); + MAP.get_or_init(|| { + let mut map = std::collections::HashMap::new(); + for lic in spdx::identifiers::LICENSES { + map.entry(lic.name.to_ascii_lowercase()).or_insert(lic.name); } - Node::And(_) => { - let mut operands = Vec::new(); - flatten(node, false, &mut operands); - // AND binds tighter than OR; wrap any OR child in parens. - let mut parts: Vec = operands - .iter() - .map(|c| match c { - Node::Or(_) => format!("({})", render(c)), - _ => render(c), - }) - .collect(); - parts.sort(); - parts.dedup(); - parts.join(" AND ") + for exc in spdx::identifiers::EXCEPTIONS { + map.entry(exc.name.to_ascii_lowercase()).or_insert(exc.name); } - } + map + }) } -/// Split a string on a top-level (non-parenthesized) ` OP ` boundary. -fn split_top_level(s: &str, op: &str) -> Vec { - let s = strip_outer_parens(s.trim()); - let bytes = s.as_bytes(); - let mut depth = 0i32; - let mut parts = Vec::new(); - let mut start = 0usize; - let needle = format!(" {op} "); - let mut i = 0usize; - while i < bytes.len() { - match bytes[i] { - b'(' => depth += 1, - b')' => depth -= 1, - _ => {} - } - if depth == 0 && s[i..].to_ascii_uppercase().starts_with(&needle) { - parts.push(s[start..i].to_string()); - i += needle.len(); - start = i; +/// True for characters inside an SPDX id token. Operators, parens, `+`, and +/// whitespace split tokens; `/` stays inside so slash-expressions reach the +/// strict parser verbatim (and stay rejected there). +fn is_id_char(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '/') +} + +/// Rewrite whole-token known ids to canonical spelling (`apache-2.0` → +/// `Apache-2.0`). Only exact (modulo case) full-token hits change, so prose, +/// operators, `LicenseRef-*`, and sloppy prefixes (`apache2`) pass through +/// untouched for the strict parser to judge. +fn casefold_known_ids(expression: &str) -> String { + let map = canonical_id_map(); + let mut out = String::with_capacity(expression.len()); + let mut token = String::new(); + for c in expression.chars() { + if is_id_char(c) { + token.push(c); continue; } - i += 1; + flush_token(&mut token, &mut out, map); + out.push(c); } - parts.push(s[start..].to_string()); - parts + flush_token(&mut token, &mut out, map); + out } -/// Remove one layer of fully-enclosing parentheses, if present. -fn strip_outer_parens(s: &str) -> &str { - let t = s.trim(); - if t.starts_with('(') && t.ends_with(')') { - // Verify the first `(` matches the last `)`. - let inner = &t[1..t.len() - 1]; - let mut depth = 0i32; - for (idx, c) in inner.char_indices() { - match c { - '(' => depth += 1, - ')' => { - depth -= 1; - if depth < 0 { - return t; // unbalanced — keep as-is +fn flush_token( + token: &mut String, + out: &mut String, + map: &std::collections::HashMap, +) { + if token.is_empty() { + return; + } + match map.get(&token.to_ascii_lowercase()) { + Some(canonical) => out.push_str(canonical), + None => out.push_str(token), + } + token.clear(); +} + +/// Strict-parse one expression string into canonical sorted-operand form. +fn parse_ast_strict(expression: &str) -> Option> { + let parsed = spdx::Expression::parse(expression).ok()?; + // Rebuild the tree from postfix nodes; same-operator runs flatten so + // associativity (`A AND (B AND C)` ≡ `(A AND B) AND C`) holds structurally. + #[derive(Debug)] + enum Ast { + Req(String), + And(Vec), + Or(Vec), + } + fn push_op(stack: &mut Vec, is_or: bool) { + let right = stack.pop(); + let left = stack.pop(); + match (left, right) { + (Some(l), Some(r)) => { + let mut children = Vec::with_capacity(2); + for child in [l, r] { + match (is_or, child) { + (true, Ast::Or(nested)) | (false, Ast::And(nested)) => { + children.extend(nested); + } + (_, other) => children.push(other), } } - _ => {} + stack.push(if is_or { + Ast::Or(children) + } else { + Ast::And(children) + }); } - let _ = idx; - } - if depth == 0 { - return inner.trim(); + // Unbalanced input cannot come from the parser; keep the survivor. + (Some(l), None) => stack.push(l), + (None, Some(r)) => stack.push(r), + (None, None) => {} } } - t -} - -/// Normalize a single leaf token: trim, recurse into parens, canonicalize id case and `+`, -/// and preserve `WITH` exceptions. -fn normalize_leaf(s: &str) -> String { - let t = strip_outer_parens(s.trim()); - if t.is_empty() { - return String::new(); + let mut stack: Vec = Vec::new(); + for node in parsed.iter() { + match node { + spdx::expression::ExprNode::Req(req) => { + stack.push(Ast::Req(requirement_text(&req.req))); + } + spdx::expression::ExprNode::Op(spdx::expression::Operator::Or) => { + push_op(&mut stack, true); + } + spdx::expression::ExprNode::Op(spdx::expression::Operator::And) => { + push_op(&mut stack, false); + } + } } - // Recurse if the leaf still contains top-level operators (was parenthesized). - if split_top_level(t, "OR").len() > 1 || split_top_level(t, "AND").len() > 1 { - return format!("({})", normalize_boolean(t)); + if stack.len() != 1 { + return None; } - // Handle `ID WITH Exception`. - if let Some((lic, exc)) = split_with(t) { - return format!("{} WITH {}", canon_id(&lic), exc.trim()); + fn render(node: &Ast, out: &mut Vec) { + match node { + Ast::Req(text) => out.push(text.clone()), + Ast::Or(children) => { + let mut parts: Vec = children + .iter() + .map(|c| { + let mut sub = Vec::new(); + render(c, &mut sub); + sub.join(" ") + }) + .collect(); + parts.sort(); + out.extend(parts); + out.push("OR".to_string()); + } + Ast::And(children) => { + let mut parts: Vec = children + .iter() + .map(|c| { + let mut sub = Vec::new(); + render(c, &mut sub); + sub.join(" ") + }) + .collect(); + parts.sort(); + out.extend(parts); + out.push("AND".to_string()); + } + } } - canon_id(t) + let mut out = Vec::new(); + render(&stack[0], &mut out); + Some(out) } -/// Split `A WITH B` at the top level, if present. -fn split_with(s: &str) -> Option<(String, String)> { - let parts = split_top_level(s, "WITH"); - if parts.len() == 2 { - Some((parts[0].clone(), parts[1].clone())) - } else { - None - } -} - -/// Canonicalize a bare license identifier: map to the official SPDX casing when known, -/// preserve a trailing `+`, and keep `LicenseRef-*` verbatim. -fn canon_id(id: &str) -> String { - let id = id.trim(); - let (base, plus) = match id.strip_suffix('+') { - Some(b) => (b, "+"), - None => (id, ""), - }; - if is_license_ref(base) { - return format!("{base}{plus}"); - } - match spdx::license_id(base) { - Some(found) => format!("{}{plus}", found.name), - None => format!("{base}{plus}"), +/// Canonical text of one license requirement: canonical id spelling plus any +/// `WITH` exception. `LicenseRef-*` values round-trip through the same parser. +fn requirement_text(req: &spdx::LicenseReq) -> String { + let mut s = req.license.to_string(); + if let Some(addition) = &req.addition { + s.push_str(&format!(" WITH {addition}")); } + s } #[cfg(test)] @@ -249,6 +319,30 @@ mod tests { assert!(expressions_equal("MIT OR Apache-2.0", "Apache-2.0 OR MIT")); } + #[test] + fn with_exception_yields_both_ids() { + assert_eq!( + expression_ids("GPL-2.0-only WITH Classpath-exception-2.0"), + vec![ + "GPL-2.0-only".to_string(), + "Classpath-exception-2.0".to_string() + ] + ); + assert_eq!( + expression_ids("MIT\tor\tApache-2.0"), + vec!["MIT".to_string(), "Apache-2.0".to_string()] + ); + } + + #[test] + fn commutative_and_is_equal() { + // Policy AND-combinations compare order-insensitively. + assert!(expressions_equal( + "MIT AND Apache-2.0", + "Apache-2.0 AND MIT" + )); + } + #[test] fn whitespace_and_parens_normalized() { assert!(expressions_equal( @@ -299,4 +393,69 @@ mod tests { fn invalid_expression_rejected() { assert!(validate_expression("Not A Real License").is_err()); } + + #[test] + fn compound_parens_both_orders() { + assert!(expressions_equal( + "MIT AND (Apache-2.0 OR ISC)", + "(ISC OR Apache-2.0) AND MIT" + )); + assert!(expressions_equal( + "(MIT OR Apache-2.0)", + "Apache-2.0 OR (MIT)" + )); + } + + #[test] + fn operators_never_confused() { + assert!(!expressions_equal( + "MIT OR Apache-2.0", + "MIT AND Apache-2.0" + )); + // Precedence is structural: `A AND B OR C` is `(A AND B) OR C`. + assert!(expressions_equal( + "MIT AND Apache-2.0 OR ISC", + "(MIT AND Apache-2.0) OR ISC" + )); + assert!(!expressions_equal( + "MIT AND Apache-2.0 OR ISC", + "MIT AND (Apache-2.0 OR ISC)" + )); + } + + #[test] + fn tab_newline_separators() { + assert!(expressions_equal( + "MIT\tOR\nApache-2.0", + "MIT OR Apache-2.0" + )); + } + + #[test] + fn mixed_case_ids_equal() { + assert!(expressions_equal("MIT or apache-2.0", "Apache-2.0 OR MIT")); + assert!(expressions_equal( + "mit and apache-2.0", + "Apache-2.0 AND MIT" + )); + assert!(expressions_equal( + "MIT WITH Classpath-exception-2.0", + "mit with classpath-exception-2.0" + )); + } + + #[test] + fn licenseref_compound_sorts() { + assert!(expressions_equal( + "LicenseRef-Acme-1.0 OR MIT", + "MIT OR LicenseRef-Acme-1.0" + )); + } + + #[test] + fn unparseable_falls_back_to_exact() { + assert!(expressions_equal("Not A License", "Not A License")); + assert!(!expressions_equal("Not A License", "not a license")); + assert!(!expressions_equal("MIT", "Not A License")); + } } diff --git a/src/walk/cache.rs b/src/walk/cache.rs deleted file mode 100644 index 4cd72fd..0000000 --- a/src/walk/cache.rs +++ /dev/null @@ -1,180 +0,0 @@ -//! Warm-scan classification cache (SC-006, FR-023, SC-011). -//! -//! The cache key folds in file content hash **+** an effective-config fingerprint **+** -//! the tool version, so a hit is observationally identical to a cold run and can never -//! produce a stale `Compliant` (SC-011). - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use sha2::{Digest, Sha256}; - -/// Lowercase-hex encode a digest's bytes, independent of the array type `finalize` -/// returns (newer `sha2` yields a `hybrid-array` `Array` that does not impl `LowerHex`). -fn hex(bytes: &[u8]) -> String { - use std::fmt::Write; - let mut s = String::with_capacity(bytes.len() * 2); - for b in bytes { - let _ = write!(s, "{b:02x}"); - } - s -} - -/// On-disk cache mapping `path → (content+config+version key, drift label)`. -#[derive(Default)] -pub struct ScanCache { - entries: HashMap, - config_fingerprint: String, - dirty: bool, - path: Option, -} - -struct CacheEntry { - key: String, - drift: String, -} - -impl ScanCache { - /// Tool version embedded at build time (folds into the cache key). - fn tool_version() -> &'static str { - env!("CARGO_PKG_VERSION") - } - - /// Open (or create) a cache at `path` for the given config fingerprint. - pub fn open(path: &Path, config_fingerprint: &str) -> Self { - let mut cache = ScanCache { - entries: HashMap::new(), - config_fingerprint: config_fingerprint.to_string(), - dirty: false, - path: Some(path.to_path_buf()), - }; - if let Ok(text) = std::fs::read_to_string(path) { - for line in text.lines() { - // format: \t\t - let mut parts = line.splitn(3, '\t'); - if let (Some(p), Some(k), Some(d)) = (parts.next(), parts.next(), parts.next()) { - cache.entries.insert( - p.to_string(), - CacheEntry { - key: k.to_string(), - drift: d.to_string(), - }, - ); - } - } - } - cache - } - - /// A disabled (`--no-cache`) cache that never hits and never persists. - pub fn disabled() -> Self { - ScanCache::default() - } - - /// Compute the content-hash component for a file's bytes. - pub fn content_hash(bytes: &[u8]) -> String { - let mut h = Sha256::new(); - h.update(bytes); - hex(h.finalize().as_ref()) - } - - /// The full cache key folding content + config fingerprint + tool version. - fn full_key(&self, content_hash: &str) -> String { - let mut h = Sha256::new(); - h.update(content_hash.as_bytes()); - h.update(b"\0"); - h.update(self.config_fingerprint.as_bytes()); - h.update(b"\0"); - h.update(Self::tool_version().as_bytes()); - hex(h.finalize().as_ref()) - } - - /// Look up a cached drift label for a path+content, honoring the full key. - pub fn get(&self, rel_path: &str, content_hash: &str) -> Option { - self.path.as_ref()?; - let key = self.full_key(content_hash); - self.entries - .get(rel_path) - .filter(|e| e.key == key) - .map(|e| e.drift.clone()) - } - - /// Record a classification result. - pub fn put(&mut self, rel_path: &str, content_hash: &str, drift: &str) { - if self.path.is_none() { - return; - } - let key = self.full_key(content_hash); - self.entries.insert( - rel_path.to_string(), - CacheEntry { - key, - drift: drift.to_string(), - }, - ); - self.dirty = true; - } - - /// Persist the cache to disk if modified. - pub fn flush(&self) -> std::io::Result<()> { - if !self.dirty { - return Ok(()); - } - if let Some(path) = &self.path { - let mut out = String::new(); - for (p, e) in &self.entries { - out.push_str(&format!("{p}\t{}\t{}\n", e.key, e.drift)); - } - std::fs::write(path, out)?; - } - Ok(()) - } -} - -/// Compute an effective-config fingerprint for the cache key (FR-023). -pub fn config_fingerprint(config_text: &str) -> String { - let mut h = Sha256::new(); - h.update(config_text.as_bytes()); - hex(h.finalize().as_ref()) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - #[test] - fn hit_only_with_matching_key() { - let dir = tempdir().unwrap(); - let path = dir.path().join("cache"); - let mut c = ScanCache::open(&path, "fp1"); - let ch = ScanCache::content_hash(b"hello"); - c.put("a.rs", &ch, "compliant"); - assert_eq!(c.get("a.rs", &ch).as_deref(), Some("compliant")); - // Different content → miss. - assert!(c.get("a.rs", &ScanCache::content_hash(b"world")).is_none()); - } - - #[test] - fn config_change_invalidates() { - let dir = tempdir().unwrap(); - let path = dir.path().join("cache"); - let ch = ScanCache::content_hash(b"hello"); - { - let mut c = ScanCache::open(&path, "fp1"); - c.put("a.rs", &ch, "compliant"); - c.flush().unwrap(); - } - // Reopen with a different fingerprint → stale entry must not hit. - let c2 = ScanCache::open(&path, "fp2"); - assert!(c2.get("a.rs", &ch).is_none()); - } - - #[test] - fn disabled_never_hits() { - let mut c = ScanCache::disabled(); - let ch = ScanCache::content_hash(b"x"); - c.put("a", &ch, "compliant"); - assert!(c.get("a", &ch).is_none()); - } -} diff --git a/src/walk/git.rs b/src/walk/git.rs new file mode 100644 index 0000000..7cf18b9 --- /dev/null +++ b/src/walk/git.rs @@ -0,0 +1,697 @@ +//! System-Git repository discovery and index-snapshot reads (F06, F07). +//! +//! All repository structure comes from checked `git` subprocesses (never shell +//! text): root discovery, the tracked index (`ls-files --stage -z`), subsets +//! (`diff --name-only -z`), and blob bytes (one `git cat-file --batch` process +//! per scan, addressed by object id). NUL-delimited output is decoded losslessly +//! — Unix paths keep their exact bytes through lookup and writes. +//! +//! [`Snapshot`] is the single read surface both sources implement, so detection, +//! metadata, configuration, and license-text inventory always observe the same +//! selected content ([`crate::domain::ContentSource`]). + +use std::collections::{HashMap, HashSet}; +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; + +use crate::domain::ContentSource; +use crate::error::{LicetError, Result}; + +/// Run a checked `git` command in `root`; return raw stdout. Failures retain +/// stderr context. Arguments are passed as an argv array — never shell text. +pub fn git_output(root: &Path, args: &[&OsStr]) -> Result> { + let output = std::process::Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .output() + .map_err(|e| LicetError::Git(format!("failed to launch git: {e}")))?; + if !output.status.success() { + let stderr: String = String::from_utf8_lossy(&output.stderr) + .chars() + .take(500) + .collect(); + return Err(LicetError::Git(format!( + "git {} failed: {}", + args.iter() + .map(|a| a.to_string_lossy().into_owned()) + .collect::>() + .join(" "), + stderr.trim() + ))); + } + Ok(output.stdout) +} + +/// A repository with a working tree, discovered via system Git. +#[derive(Debug, Clone)] +pub struct GitRepo { + /// Absolute working-tree root (`rev-parse --show-toplevel`). + pub root: PathBuf, + /// Absolute Git metadata directory (`rev-parse --absolute-git-dir`). + pub git_dir: PathBuf, +} + +/// How `start` relates to a Git repository. +#[derive(Debug)] +pub enum RepoDisposition { + /// A repository with a working tree. + Repo(GitRepo), + /// Not inside a Git working tree: filesystem fallback applies. + NonRepo, + /// A bare repository (no working tree to evaluate). + Bare, +} + +/// Discover the repository disposition of `start`, distinguishing "not a +/// repository" from Git being missing or failing (F13). +pub fn discover_repo(start: &Path) -> Result { + let output = std::process::Command::new("git") + .arg("-C") + .arg(start) + .args(["rev-parse", "--show-toplevel", "--absolute-git-dir"]) + .output() + .map_err(|e| LicetError::Git(format!("failed to launch git: {e}")))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("not a git repository") { + return Ok(RepoDisposition::NonRepo); + } + if stderr.contains("must be run in a work tree") { + return Ok(RepoDisposition::Bare); + } + let excerpt: String = stderr.chars().take(300).collect(); + return Err(LicetError::Git(format!( + "cannot determine repository state: {}", + excerpt.trim() + ))); + } + let mut lines = output.stdout.split(|b| *b == b'\n'); + let toplevel = lines.next().unwrap_or_default(); + let git_dir = lines.next().unwrap_or_default(); + // Exactly two trailing-newline-terminated lines; anything else (notably an + // embedded newline in a path) is an explicit failure, not a guess. + if toplevel.is_empty() || git_dir.is_empty() || lines.next().is_some_and(|l| !l.is_empty()) { + return Err(LicetError::Git( + "cannot parse `git rev-parse` output for the repository root".to_string(), + )); + } + let root = bytes_to_path(toplevel)?; + let git_dir = bytes_to_path(git_dir)?; + Ok(RepoDisposition::Repo(GitRepo { root, git_dir })) +} + +/// Decode NUL-delimited command output bytes into an OS-native path, losslessly +/// on Unix. A path that cannot be represented is an explicit failure, never a +/// lossy replacement (F07). +#[cfg(unix)] +fn bytes_to_path(bytes: &[u8]) -> Result { + use std::os::unix::ffi::OsStringExt; + Ok(PathBuf::from(OsString::from_vec(bytes.to_vec()))) +} + +/// Decode NUL-delimited command output bytes into a path (non-Unix). +#[cfg(not(unix))] +fn bytes_to_path(bytes: &[u8]) -> Result { + match std::str::from_utf8(bytes) { + Ok(s) => Ok(PathBuf::from(s)), + Err(_) => Err(LicetError::Git( + "git returned a path that is not valid Unicode on this platform".to_string(), + )), + } +} + +/// Split NUL-delimited output into raw path byte strings, dropping the trailing empty. +fn split_nul_paths(out: &[u8]) -> Vec<&[u8]> { + let mut parts: Vec<&[u8]> = out.split(|b| *b == 0).collect(); + if parts.last() == Some(&&[][..]) { + parts.pop(); + } + parts.into_iter().filter(|p| !p.is_empty()).collect() +} + +/// One index entry from `ls-files --stage -z`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IndexEntry { + /// File mode (e.g. `0o100644` regular, `0o120000` symlink, `0o160000` gitlink). + pub mode: u32, + /// Blob/object id (hex). + pub oid: String, + /// Merge stage (`0` = resolved). + pub stage: u32, +} + +impl IndexEntry { + /// True for a regular file blob (not symlink, gitlink, …). + pub fn is_regular_file(&self) -> bool { + self.mode & 0o170000 == 0o100000 + } +} + +/// Parse one `ls-files --stage -z` record: ` SP SP TAB `. +fn parse_stage_record(rec: &[u8]) -> Result<(PathBuf, IndexEntry)> { + let tab = rec + .iter() + .position(|b| *b == b'\t') + .ok_or_else(|| LicetError::Git("cannot parse `git ls-files --stage` output".to_string()))?; + let (meta, path_bytes) = rec.split_at(tab); + let path_bytes = &path_bytes[1..]; + let meta = std::str::from_utf8(meta) + .map_err(|_| LicetError::Git("cannot parse `git ls-files --stage` output".to_string()))?; + let mut parts = meta.split(' '); + let mode = parts.next().and_then(|m| u32::from_str_radix(m, 8).ok()); + let oid = parts.next().map(str::to_string); + let stage = parts.next().and_then(|s| s.parse::().ok()); + match (mode, oid, stage) { + (Some(mode), Some(oid), Some(stage)) => { + Ok((bytes_to_path(path_bytes)?, IndexEntry { mode, oid, stage })) + } + _ => Err(LicetError::Git( + "cannot parse `git ls-files --stage` output".to_string(), + )), + } +} + +/// The full tracked index plus prefetched blobs: one consistent snapshot. +#[derive(Debug, Clone)] +pub struct IndexSnapshot { + /// All index entries by root-relative path (lossless). + pub entries: HashMap, + blobs: HashMap>, +} + +impl IndexSnapshot { + /// Load every index entry. Blob bytes arrive via [`IndexSnapshot::prefetch`]. + pub fn load(root: &Path) -> Result { + let out = git_output( + root, + &[ + OsStr::new("ls-files"), + OsStr::new("--stage"), + OsStr::new("-z"), + ], + )?; + let mut entries = HashMap::new(); + for rec in split_nul_paths(&out) { + let (path, entry) = parse_stage_record(rec)?; + entries.insert(path, entry); + } + Ok(IndexSnapshot { + entries, + blobs: HashMap::new(), + }) + } + + /// Regular-file paths in the index (submodules and symlinks excluded). + pub fn regular_files(&self) -> Vec { + let mut paths: Vec = self + .entries + .iter() + .filter(|(_, e)| e.is_regular_file() && e.stage == 0) + .map(|(p, _)| p.clone()) + .collect(); + paths.sort(); + paths + } + + /// Fetch every blob for `paths` (plus their sidecars and `extra`, e.g. the + /// effective config) with a single `git cat-file --batch` process addressed + /// by object id. + pub fn prefetch(&mut self, root: &Path, paths: &[PathBuf], extra: &[PathBuf]) -> Result<()> { + let mut oids: HashSet<&str> = HashSet::new(); + let mut want: Vec = Vec::with_capacity(paths.len() * 2 + extra.len()); + for p in paths { + want.push(p.clone()); + want.push(sidecar_for(p)); + } + want.extend(extra.iter().cloned()); + // Repository-level metadata and every license text participate in the + // same snapshot so staged metadata/config/text changes are visible. + // `REUSE.toml` documents live at any depth; every one of them (plus + // the root `.reuse/dep5`) is prefetched so nested staged metadata + // resolves through the index, never the working copy. + want.push(PathBuf::from("REUSE.toml")); + want.push(PathBuf::from(".reuse/dep5")); + for (p, e) in &self.entries { + if e.is_regular_file() + && p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n == "REUSE.toml") + { + want.push(p.clone()); + } + } + for (p, e) in &self.entries { + if e.is_regular_file() && p.starts_with("LICENSES/") { + want.push(p.clone()); + } + } + for p in &want { + if let Some(e) = self.entries.get(p) + && e.is_regular_file() + && e.stage == 0 + { + oids.insert(e.oid.as_str()); + } + } + // Skip objects already held. + let missing: Vec<&str> = oids + .into_iter() + .filter(|o| !self.blobs.contains_key(*o)) + .collect(); + if missing.is_empty() { + return Ok(()); + } + let mut request = Vec::new(); + for oid in &missing { + request.extend_from_slice(oid.as_bytes()); + request.push(b'\n'); + } + let mut child = std::process::Command::new("git") + .arg("-C") + .arg(root) + .args(["cat-file", "--batch"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|e| LicetError::Git(format!("failed to launch git cat-file: {e}")))?; + use std::io::Write; + child + .stdin + .as_mut() + .ok_or_else(|| LicetError::Git("git cat-file has no stdin".to_string()))? + .write_all(&request) + .map_err(|e| LicetError::Git(format!("failed to query git cat-file: {e}")))?; + drop(child.stdin.take()); + let output = child + .wait_with_output() + .map_err(|e| LicetError::Git(format!("git cat-file failed: {e}")))?; + if !output.status.success() { + return Err(LicetError::Git("git cat-file --batch failed".to_string())); + } + parse_batch_output(&output.stdout, &missing, &mut self.blobs)?; + Ok(()) + } + + /// Blob bytes for a root-relative path: `None` when absent from the index, + /// a gitlink, or a symlink (both ignored per REUSE, never followed). + /// Unmerged entries and unknown objects are errors, never silent content. + pub fn read(&self, rel: &Path) -> Result>> { + let entry = match self.entries.get(rel) { + Some(e) => e, + None => return Ok(None), + }; + if entry.stage != 0 { + return Err(LicetError::Git(format!( + "cannot evaluate {}: unresolved merge stage {} in the index", + rel.display(), + entry.stage + ))); + } + if !entry.is_regular_file() { + return Ok(None); + } + match self.blobs.get(&entry.oid) { + Some(bytes) => Ok(Some(bytes.clone())), + None => Err(LicetError::Internal(format!( + "index object {} for {} was not prefetched", + entry.oid, + rel.display() + ))), + } + } + + /// Names (`MIT`, `LicenseRef-X`, …) of regular `LICENSES/*.txt` blobs. + pub fn license_text_names(&self) -> HashSet { + let mut names = HashSet::new(); + for (p, e) in &self.entries { + if !e.is_regular_file() || e.stage != 0 { + continue; + } + // Exactly `LICENSES/.txt` (component-wise; nested LICENSES + // directories are not a project-wide exemption). + if p.parent() != Some(Path::new("LICENSES")) { + continue; + } + let is_txt = p.extension().map(|x| x == "txt").unwrap_or(false); + let Some(stem) = p.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + if is_txt { + names.insert(stem.to_string()); + } + } + names + } +} + +/// Parse `cat-file --batch` output for exactly the requested oids: +/// ` SP blob SP LF LF`, or ` SP missing LF`. +fn parse_batch_output( + out: &[u8], + missing: &[&str], + blobs: &mut HashMap>, +) -> Result<()> { + let mut rest = out; + let mut seen: HashSet<&str> = HashSet::new(); + for oid in missing { + // Header line. + let nl = rest.iter().position(|b| *b == b'\n').ok_or_else(|| { + LicetError::Git("truncated `git cat-file --batch` output".to_string()) + })?; + let header = std::str::from_utf8(&rest[..nl]).map_err(|_| { + LicetError::Git("cannot parse `git cat-file --batch` output".to_string()) + })?; + rest = &rest[nl + 1..]; + let mut parts = header.split(' '); + let got_oid = parts.next().unwrap_or_default(); + let kind = parts.next().unwrap_or_default(); + if got_oid != *oid { + return Err(LicetError::Git(format!( + "git cat-file answered {got_oid} for requested {oid}" + ))); + } + if kind == "missing" { + return Err(LicetError::Git(format!( + "index object {oid} is missing from the object store" + ))); + } + if kind != "blob" { + return Err(LicetError::Git(format!( + "index object {oid} is a {kind}, not a blob" + ))); + } + let size: usize = parts + .next() + .and_then(|s| s.parse().ok()) + .ok_or_else(|| LicetError::Git("cannot parse `git cat-file` blob size".to_string()))?; + if rest.len() < size + 1 || rest[size] != b'\n' { + return Err(LicetError::Git( + "truncated `git cat-file --batch` blob".to_string(), + )); + } + blobs.insert(oid.to_string(), rest[..size].to_vec()); + rest = &rest[size + 1..]; + seen.insert(*oid); + } + Ok(()) +} + +/// `.license`: the sidecar companion of a root-relative path. +pub fn sidecar_for(rel: &Path) -> PathBuf { + let mut os = rel.as_os_str().to_owned(); + os.push(".license"); + PathBuf::from(os) +} + +/// Validate `rev` as a commit and return its object id (hex), so only a +/// resolved id ever reaches diff (F07). +pub fn validate_rev_to_commit(root: &Path, rev: &str) -> Result { + let arg = format!("{rev}^{{commit}}"); + let out = git_output( + root, + &[ + OsStr::new("rev-parse"), + OsStr::new("--verify"), + OsStr::new("--quiet"), + OsStr::new(&arg), + ], + ) + .map_err(|_| LicetError::Config(format!("`{rev}` does not resolve to a commit")))?; + let oid = String::from_utf8_lossy(&out).trim().to_string(); + if oid.len() != 40 || !oid.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(LicetError::Config(format!( + "`{rev}` does not resolve to a commit" + ))); + } + Ok(oid) +} + +/// Index-vs-HEAD path set for `--staged`, or the full index on unborn HEAD +/// (where diff-vs-HEAD cannot run). Returns `(paths, full_index)` where the flag +/// records the unborn-HEAD fallback. +pub fn staged_path_set(root: &Path, index: &IndexSnapshot) -> Result<(Vec, bool)> { + let head = git_output( + root, + &[ + OsStr::new("rev-parse"), + OsStr::new("--verify"), + OsStr::new("--quiet"), + OsStr::new("HEAD^{commit}"), + ], + ); + if head.is_err() { + // Unborn HEAD: every indexed regular file is the staged set. + return Ok((index.regular_files(), true)); + } + let out = git_output( + root, + &[ + OsStr::new("diff"), + OsStr::new("--name-only"), + OsStr::new("-z"), + OsStr::new("--cached"), + OsStr::new("--diff-filter=d"), + OsStr::new("--"), + ], + )?; + let mut paths = Vec::new(); + for raw in split_nul_paths(&out) { + paths.push(bytes_to_path(raw)?); + } + paths.sort(); + Ok((paths, false)) +} + +/// Staged deletions (filtered out of the evaluable set): a deleted metadata file +/// can still affect other files, so deletions feed expansion triggers. Empty on +/// unborn HEAD, where the evaluable set is already the full index. +pub fn staged_deleted_paths(root: &Path) -> Result> { + let head = git_output( + root, + &[ + OsStr::new("rev-parse"), + OsStr::new("--verify"), + OsStr::new("--quiet"), + OsStr::new("HEAD^{commit}"), + ], + ); + if head.is_err() { + return Ok(Vec::new()); + } + let out = git_output( + root, + &[ + OsStr::new("diff"), + OsStr::new("--name-only"), + OsStr::new("-z"), + OsStr::new("--cached"), + OsStr::new("--diff-filter=D"), + OsStr::new("--"), + ], + )?; + let mut paths = Vec::new(); + for raw in split_nul_paths(&out) { + paths.push(bytes_to_path(raw)?); + } + paths.sort(); + Ok(paths) +} + +/// Working-tree-vs-commit path set for `--changed `. +pub fn changed_path_set(root: &Path, commit_oid: &str) -> Result> { + let out = git_output( + root, + &[ + OsStr::new("diff"), + OsStr::new("--name-only"), + OsStr::new("-z"), + OsStr::new("--diff-filter=d"), + OsStr::new(commit_oid), + OsStr::new("--"), + ], + )?; + let mut paths = Vec::new(); + for raw in split_nul_paths(&out) { + paths.push(bytes_to_path(raw)?); + } + paths.sort(); + Ok(paths) +} + +/// Worktree deletions vs a commit: trigger-only paths for `--changed` expansion. +pub fn changed_deleted_paths(root: &Path, commit_oid: &str) -> Result> { + let out = git_output( + root, + &[ + OsStr::new("diff"), + OsStr::new("--name-only"), + OsStr::new("-z"), + OsStr::new("--diff-filter=D"), + OsStr::new(commit_oid), + OsStr::new("--"), + ], + )?; + let mut paths = Vec::new(); + for raw in split_nul_paths(&out) { + paths.push(bytes_to_path(raw)?); + } + paths.sort(); + Ok(paths) +} + +/// Tracked-plus-nonignored-untracked paths for Git-backed `lint`. +pub fn lint_path_set(root: &Path, index: &IndexSnapshot) -> Result> { + let mut set: HashSet = index.entries.keys().cloned().collect(); + let out = git_output( + root, + &[ + OsStr::new("ls-files"), + OsStr::new("--others"), + OsStr::new("--exclude-standard"), + OsStr::new("-z"), + ], + )?; + for raw in split_nul_paths(&out) { + set.insert(bytes_to_path(raw)?); + } + let mut paths: Vec = set.into_iter().collect(); + paths.sort(); + Ok(paths) +} + +/// The single read surface: working-tree files or one prefetched index. +#[derive(Debug, Clone)] +pub enum Snapshot { + Worktree { root: PathBuf }, + Index(IndexSnapshot), +} + +impl Snapshot { + pub fn source(&self) -> ContentSource { + match self { + Snapshot::Worktree { .. } => ContentSource::Worktree, + Snapshot::Index(_) => ContentSource::Index, + } + } + + /// File bytes at a root-relative path: `None` when absent (or ignored by + /// kind: symlink, gitlink). Read/permission failures are errors with the + /// path attached — never silent absence (F13). + pub fn read(&self, rel: &Path) -> Result>> { + match self { + Snapshot::Index(index) => index.read(rel), + Snapshot::Worktree { root } => match std::fs::read(root.join(rel)) { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(LicetError::Io(std::io::Error::new( + e.kind(), + format!("cannot read {}: {e}", rel.display()), + ))), + }, + } + } + + /// Candidate license-text files: root `LICENSES/` top-level entries in this + /// snapshot (sorted). Symlinks and non-regular entries never count (F02); + /// name analysis (suffixes, recognized ids, duplicates) lives in the + /// inventory, which reads each candidate through this same snapshot. + pub fn license_text_candidates(&self) -> Vec { + let mut out = match self { + Snapshot::Index(index) => index + .entries + .iter() + .filter(|(p, e)| e.is_regular_file() && p.parent() == Some(Path::new("LICENSES"))) + .map(|(p, _)| p.clone()) + .collect(), + Snapshot::Worktree { root } => { + let mut names = Vec::new(); + let dir = root.join("LICENSES"); + if let Ok(entries) = std::fs::read_dir(&dir) { + for e in entries.flatten() { + let path = e.path(); + let Ok(meta) = std::fs::symlink_metadata(&path) else { + continue; + }; + if !meta.file_type().is_file() { + continue; + } + if let Ok(rel) = path.strip_prefix(root) { + names.push(rel.to_path_buf()); + } + } + } + names + } + }; + out.sort(); + out + } + + /// Identifiers with a text file under `LICENSES/` in this snapshot. + /// Symlinked entries never count (F02). + pub fn license_text_names(&self) -> HashSet { + match self { + Snapshot::Index(index) => index.license_text_names(), + Snapshot::Worktree { root } => { + let mut names = HashSet::new(); + let dir = root.join("LICENSES"); + if let Ok(entries) = std::fs::read_dir(&dir) { + for e in entries.flatten() { + let path = e.path(); + if path.extension().map(|x| x == "txt").unwrap_or(false) + && let Some(stem) = path.file_stem().and_then(|s| s.to_str()) + && let Ok(meta) = std::fs::symlink_metadata(&path) + && meta.file_type().is_file() + { + names.insert(stem.to_string()); + } + } + } + names + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_stage_record() { + let rec = b"100644 abcdef0123456789abcdef0123456789abcdef01 0\tcaf\xc3\xa9.rs"; + let (path, entry) = parse_stage_record(rec).unwrap(); + assert_eq!(path, PathBuf::from("caf\u{e9}.rs")); + assert_eq!(entry.mode, 0o100644); + assert_eq!(entry.stage, 0); + assert!(entry.is_regular_file()); + } + + #[test] + fn parses_stage_modes() { + let rec = b"120000 0000000000000000000000000000000000000000 0\tlink"; + let (_, entry) = parse_stage_record(rec).unwrap(); + assert!(!entry.is_regular_file()); + let rec = b"160000 abcdef0123456789abcdef0123456789abcdef01 0\tsub"; + let (_, entry) = parse_stage_record(rec).unwrap(); + assert!(!entry.is_regular_file()); + } + + #[test] + fn parses_batch_blob_exactly() { + let mut blobs = HashMap::new(); + // size 4: content bytes `AB\nC`, then the framing LF, then trailing output. + let out = b"deadbeef blob 4\nAB\nC\nTRAILING"; + parse_batch_output(out, &["deadbeef"], &mut blobs).unwrap(); + assert_eq!(blobs["deadbeef"], b"AB\nC"); + } + + #[test] + fn batch_missing_is_an_error() { + let mut blobs = HashMap::new(); + let out = b"deadbeef missing\n"; + assert!(parse_batch_output(out, &["deadbeef"], &mut blobs).is_err()); + } +} diff --git a/src/walk/mod.rs b/src/walk/mod.rs index 836a085..da3791b 100644 --- a/src/walk/mod.rs +++ b/src/walk/mod.rs @@ -1,24 +1,32 @@ -//! File enumeration: full gitignore-aware tree walk (`ignore`), git subset selection -//! (staged/changed/file-list), symlink-dedup, and exclusion filtering -//! (FR-013, FR-016, FR-027, Edge Cases). +//! File enumeration over a consistent snapshot (F06, F07). +//! +//! Default policy operations cover **tracked regular files** in a Git +//! repository (never the ignore-walk, so a later `.gitignore` rule cannot drop +//! a tracked file and untracked files cannot sneak into the gate); `lint` +//! additionally covers nonignored untracked files. Outside a repository the +//! nonignored filesystem walk is retained. Reads always go through +//! [`git::Snapshot`] so `--staged` evaluates index blobs — including staged +//! metadata, configuration, and license texts — while everything else reads +//! working-tree bytes. Explicit file arguments are normalized lexically +//! (no symlink resolution) and rejected when outside the root. -pub mod cache; +pub mod git; -use std::collections::HashSet; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; -use globset::{Glob, GlobSet, GlobSetBuilder}; use ignore::WalkBuilder; use ignore::overrides::OverrideBuilder; use crate::error::{LicetError, Result}; +pub use git::{GitRepo, Snapshot}; + /// Which files an invocation evaluates (selection flags are mutually exclusive, FR-027). #[derive(Debug, Clone)] pub enum Selection { /// Default: the full tracked/working tree. FullTree, - /// An explicit list of repo-relative or absolute paths. + /// An explicit list of paths (relative to cwd, or absolute). Files(Vec), /// Git-staged files (index vs HEAD). Staged, @@ -26,111 +34,554 @@ pub enum Selection { Changed(Option), } -/// Discover the repository root (git work-dir) starting from `start`, falling back to -/// `start` itself when not in a git repo. -pub fn discover_root(start: &Path) -> PathBuf { - match gix::discover(start) { - Ok(repo) => repo - .workdir() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| start.to_path_buf()), - Err(_) => start.to_path_buf(), - } -} - -/// Patterns always excluded from coverage: the `LICENSES/` text tree, `.reuse/` metadata, -/// `REUSE.toml`, and `*.license` sidecars are REUSE infrastructure, not annotatable source -/// — a sidecar is checked through its companion asset, never on its own (FR-014, FR-015, -/// FR-016). -const IMPLICIT_EXCLUDES: &[&str] = &[ - ".empty", - ".reuse/**", - "*.license", - "**/*.empty", - "**/*.license", - "**/LICENSES/**", - "**/REUSE.toml", - "LICENSES/**", - "REUSE.toml", -]; - -/// Build the exclusion matcher from config glob patterns plus implicit REUSE excludes. -fn build_excludes(patterns: &[String]) -> Result { - let mut builder = GlobSetBuilder::new(); - for p in IMPLICIT_EXCLUDES { - builder.add(Glob::new(p).expect("valid implicit exclude")); - } - for p in patterns { - let glob = Glob::new(p) - .map_err(|e| LicetError::Config(format!("invalid exclude glob `{p}`: {e}")))?; - builder.add(glob); - } - builder - .build() - .map_err(|e| LicetError::Internal(format!("glob build: {e}"))) +/// What the evaluated file set is for: declared-policy operations honor +/// `[exclude]`; REUSE validation (`lint`) never does. +/// +/// `Policy` (used by `check`) evaluates `--staged` from index blobs so the +/// gate sees the commit as it would land. `Apply` (used by `apply`) uses the +/// same path sets but always reads the working tree — `apply --staged` edits +/// working-tree files and must never expand its edit set beyond the staged +/// paths because of staged metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Purpose { + Policy, + Apply, + Lint, } -/// A discovered file: repo-relative path plus whether it is excluded. +/// A discovered file: root-relative path (lossless, OS-native) plus whether +/// REUSE itself ignores it. Declaration exclusions are applied later by the +/// engine so `lint` can skip them. #[derive(Debug, Clone)] pub struct Discovered { pub rel_path: PathBuf, pub abs_path: PathBuf, - pub excluded: bool, + /// REUSE-level ignore (LICENSES/, `.reuse/`, sidecar-as-file, zero-byte, + /// symlink, SPDX document, …) — never a covered file. + pub reuse_ignored: bool, } -/// Enumerate files to evaluate, applying selection, exclusions, and symlink dedup. -pub fn enumerate( - root: &Path, +/// Discover the repository root (git work-dir) starting from `start`, falling +/// back to `start` itself when not in a git repo. A bare repository (no working +/// tree to evaluate) is an explicit error. +pub fn discover_root(start: &Path) -> Result<(PathBuf, Option)> { + match git::discover_repo(start)? { + git::RepoDisposition::Repo(repo) => Ok((repo.root.clone(), Some(repo))), + git::RepoDisposition::NonRepo => Ok((start.to_path_buf(), None)), + git::RepoDisposition::Bare => Err(LicetError::Config( + "bare git repository has no working tree to evaluate".to_string(), + )), + } +} + +/// A fully resolved evaluation plan: what to read, from which snapshot, and +/// the configuration text observed through that same snapshot. +pub struct Prepared { + pub root: PathBuf, + pub source: crate::domain::ContentSource, + pub snapshot: Snapshot, + /// Final evaluated files (sorted by path), with REUSE-ignore status. + pub paths: Vec, + /// True when a staged/changed subset was expanded to full coverage because + /// staged metadata could affect other files. + pub expanded: bool, + /// Human note for `expanded` (becomes a report warning). + pub expansion_note: Option, + /// Configuration text read through the snapshot. + pub config_text: String, +} + +/// Resolve `selection` into paths + snapshot + configuration, without parsing +/// the configuration (commands parse it and hand it to the engine). +pub fn prepare( + cwd: &Path, + config_arg: &Path, selection: &Selection, - exclude_patterns: &[String], -) -> Result> { - let excludes = build_excludes(exclude_patterns)?; - let rel_paths = match selection { - Selection::FullTree => walk_full_tree(root)?, - Selection::Files(files) => normalize_file_list(root, files), - Selection::Staged => git_subset( - root, - &["diff", "--name-only", "--cached", "--diff-filter=d"], - )?, + purpose: Purpose, + allow_missing_config: bool, +) -> Result { + let (root, repo) = discover_root(cwd)?; + let config_rel = resolve_config_rel(cwd, &root, config_arg); + + match selection { + Selection::FullTree => { + let universe = match (&repo, purpose) { + (Some(repo), Purpose::Policy | Purpose::Apply) => { + let index = git::IndexSnapshot::load(&repo.root)?; + index.regular_files() + } + (Some(repo), Purpose::Lint) => { + let index = git::IndexSnapshot::load(&repo.root)?; + git::lint_path_set(&repo.root, &index)? + } + (None, _) => walk_worktree(&root)?, + }; + let snapshot = Snapshot::Worktree { root: root.clone() }; + let config_text = read_config_text( + &snapshot, + &root, + config_rel.as_deref(), + purpose, + allow_missing_config, + )?; + let paths = mark_sorted(&root, &snapshot, &universe)?; + Ok(Prepared { + root, + source: crate::domain::ContentSource::Worktree, + snapshot, + paths, + expanded: false, + expansion_note: None, + config_text, + }) + } + Selection::Files(files) => { + let universe = normalize_file_list(&root, cwd, files)?; + let snapshot = Snapshot::Worktree { root: root.clone() }; + let config_text = read_config_text( + &snapshot, + &root, + config_rel.as_deref(), + purpose, + allow_missing_config, + )?; + let paths = mark_sorted(&root, &snapshot, &universe)?; + Ok(Prepared { + root, + source: crate::domain::ContentSource::Worktree, + snapshot, + paths, + expanded: false, + expansion_note: None, + config_text, + }) + } + Selection::Staged => { + let repo = repo.ok_or_else(|| { + LicetError::Config("--staged requires a git repository".to_string()) + })?; + let mut index = git::IndexSnapshot::load(&repo.root)?; + let (subset, _unborn) = git::staged_path_set(&repo.root, &index)?; + if matches!(purpose, Purpose::Apply) { + // Mutating runs read the working tree over exactly the staged + // path set: edits must never spill beyond it, and the index is + // never written. + let snapshot = Snapshot::Worktree { root: root.clone() }; + let config_text = read_config_text( + &snapshot, + &root, + config_rel.as_deref(), + Purpose::Policy, + allow_missing_config, + )?; + let paths = mark_sorted(&root, &snapshot, &subset)?; + return Ok(Prepared { + root, + source: crate::domain::ContentSource::Worktree, + snapshot, + paths, + expanded: false, + expansion_note: None, + config_text, + }); + } + // An unresolved merge poisons the whole index snapshot, not just the + // evaluated subset. + let unmerged: Vec = index + .entries + .iter() + .filter(|(_, e)| e.stage != 0) + .map(|(p, _)| p.clone()) + .collect(); + if !unmerged.is_empty() { + return Err(LicetError::Config(format!( + "cannot evaluate the staged snapshot: unresolved merge entries: {}", + display_list(&unmerged) + ))); + } + let deleted = git::staged_deleted_paths(&repo.root)?; + let (universe, expanded, note) = + maybe_expand(&index, subset, &deleted, config_rel.as_deref()); + let extra: Vec = config_rel.clone().into_iter().collect(); + index.prefetch(&repo.root, &universe, &extra)?; + let snapshot = Snapshot::Index(index); + let config_text = read_config_text( + &snapshot, + &root, + config_rel.as_deref(), + purpose, + allow_missing_config, + )?; + let paths = mark_sorted(&root, &snapshot, &universe)?; + Ok(Prepared { + root, + source: crate::domain::ContentSource::Index, + snapshot, + paths, + expanded, + expansion_note: note, + config_text, + }) + } Selection::Changed(rev) => { + let repo = repo.ok_or_else(|| { + LicetError::Config("--changed requires a git repository".to_string()) + })?; let rev = rev.clone().unwrap_or_else(|| "HEAD".to_string()); - git_subset(root, &["diff", "--name-only", "--diff-filter=d", &rev])? + let oid = git::validate_rev_to_commit(&repo.root, &rev)?; + let subset = git::changed_path_set(&repo.root, &oid)?; + let deleted = git::changed_deleted_paths(&repo.root, &oid)?; + let index = git::IndexSnapshot::load(&repo.root)?; + // Mutating runs never expand: edits stay within the chosen set. + let (universe, expanded, note) = if matches!(purpose, Purpose::Apply) { + (subset, false, None) + } else { + maybe_expand(&index, subset, &deleted, config_rel.as_deref()) + }; + let snapshot = Snapshot::Worktree { root: root.clone() }; + let config_text = read_config_text( + &snapshot, + &root, + config_rel.as_deref(), + purpose, + allow_missing_config, + )?; + let paths = mark_sorted(&root, &snapshot, &universe)?; + Ok(Prepared { + root, + source: crate::domain::ContentSource::Worktree, + snapshot, + paths, + expanded, + expansion_note: note, + config_text, + }) } + } +} + +/// Resolve the config argument to a root-relative path when it stays inside the +/// root; `None` when it points outside (worktree reads only — an index snapshot +/// cannot contain it). +fn resolve_config_rel(cwd: &Path, root: &Path, config_arg: &Path) -> Option { + // Omitted configs arrive as absolute `/license.toml` (see + // `CommonArgs::config_arg`); every explicit relative path stays + // invocation-cwd-relative. + let abs = if config_arg.is_absolute() { + config_arg.to_path_buf() + } else { + cwd.join(config_arg) }; + let norm = lexical_normalize(&abs); + norm.strip_prefix(root).map(|p| p.to_path_buf()).ok() +} + +/// Read configuration text for policy commands: through the snapshot when the +/// config lives inside the root, directly from the filesystem otherwise. +/// Absence and undecodable content are errors, never silent defaults (F13) — +/// except when `allow_missing_config` tolerates a missing file (kept for +/// `add-license --all`, which still scans headers without a config; task 9 +/// distinguishes omitted vs explicit config paths). +fn read_config_text( + snapshot: &Snapshot, + root: &Path, + config_rel: Option<&Path>, + purpose: Purpose, + allow_missing_config: bool, +) -> Result { + match purpose { + Purpose::Lint => { + // Task 5 owns lint's config semantics; until then preserve the + // lenient read (missing/unparseable content falls back later). + let path = match config_rel { + Some(rel) => root.join(rel), + None => PathBuf::from("license.toml"), + }; + Ok(std::fs::read_to_string(path).unwrap_or_default()) + } + Purpose::Policy | Purpose::Apply => match config_rel { + Some(rel) => read_snapshot_config(snapshot, rel, root, allow_missing_config), + None => Err(LicetError::Config( + "config file is outside the project root".to_string(), + )), + }, + } +} - // Symlink dedup: a file reached via symlink is annotated once (Edge Cases). - let mut seen_targets: HashSet = HashSet::new(); - let mut out = Vec::new(); - for rel in rel_paths { - let abs = root.join(&rel); - let canonical = abs.canonicalize().unwrap_or_else(|_| abs.clone()); - if !seen_targets.insert(canonical) { - continue; // already annotated via another path +/// Read configuration through a snapshot (staged checks included): the file +/// must exist in that same snapshot inside the root, otherwise exit 2. +fn read_snapshot_config( + snapshot: &Snapshot, + rel: &Path, + root: &Path, + allow_missing_config: bool, +) -> Result { + match snapshot.read(rel)? { + Some(bytes) => String::from_utf8(bytes).map_err(|e| { + LicetError::Config(format!( + "config {} is not valid UTF-8: {e}", + root.join(rel).display() + )) + }), + None if allow_missing_config => Ok(String::new()), + None => Err(LicetError::Config(format!( + "cannot read config `{}`: not present in the evaluated snapshot", + root.join(rel).display() + ))), + } +} + +/// Expand a staged/changed subset to full tracked coverage when it contains +/// licensing metadata that can affect other files (conservative full expansion; +/// always reported). Returns the final set plus the report note. +fn maybe_expand( + index: &git::IndexSnapshot, + subset: Vec, + trigger_only: &[PathBuf], + config_rel: Option<&Path>, +) -> (Vec, bool, Option) { + let triggers: Vec = subset + .iter() + .chain(trigger_only.iter()) + .filter(|p| is_metadata_affecting(p, config_rel)) + .cloned() + .collect(); + if triggers.is_empty() { + return (subset, false, None); + } + let mut universe = index.regular_files(); + // Keep explicitly selected sidecars/texts even when untracked-side inputs + // arrived via --changed (they are worktree reads; index may lack them). + for p in &subset { + if !universe.contains(p) { + universe.push(p.clone()); } - let rel_str = rel.to_string_lossy().replace('\\', "/"); - let excluded = excludes.is_match(&rel_str); + } + universe.sort(); + let note = format!( + "selection expanded to full tracked coverage: staged metadata affects other files ({})", + display_list(&triggers) + ); + (universe, true, Some(note)) +} + +/// Paths whose staged/changed state can affect files beyond themselves. +fn is_metadata_affecting(rel: &Path, config_rel: Option<&Path>) -> bool { + if Some(rel) == config_rel { + return true; + } + if rel.file_name().map(|n| n == "REUSE.toml").unwrap_or(false) { + return true; + } + if rel == Path::new(".reuse/dep5") { + return true; + } + if rel.extension().map(|e| e == "license").unwrap_or(false) { + return true; + } + if rel.starts_with("LICENSES") { + return true; + } + false +} + +fn display_list(paths: &[PathBuf]) -> String { + paths + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect::>() + .join(", ") +} + +/// Classify universe paths with their REUSE-ignore status (sorted). +fn mark_sorted(root: &Path, snapshot: &Snapshot, universe: &[PathBuf]) -> Result> { + let mut out = Vec::with_capacity(universe.len()); + for rel in universe { out.push(Discovered { - rel_path: rel, - abs_path: abs, - excluded, + rel_path: rel.clone(), + abs_path: root.join(rel), + reuse_ignored: is_reuse_ignored(snapshot, rel)?, }); } out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path)); Ok(out) } -/// VCS internals and our own cache, excluded from the walk as `ignore` overrides. +/// REUSE 3.3 "Covered and ignored Files" plus structural skips. +/// +/// Ignored: root `LICENSES/` texts (a root FILE named exactly `LICENSES` stays +/// covered), `COPYING`/`LICENSE`/`LICENCE` variants at any depth, VCS +/// internals, every `REUSE.toml`, root `.reuse/`, Meson `subprojects/`, +/// symlinks, zero-byte files, SPDX documents, and `*.license` sidecars (which +/// are checked through their companion, never on their own). A nonempty +/// `data.empty` is NOT exempt — only zero-byte content is. +fn is_reuse_ignored(snapshot: &Snapshot, rel: &Path) -> Result { + // Root LICENSES tree, but not a root file literally named `LICENSES`. + if rel != Path::new("LICENSES") && rel.starts_with("LICENSES") { + return Ok(true); + } + if let Some(name) = rel.file_name().and_then(|n| n.to_str()) { + if is_license_filename(name) || name == "REUSE.toml" { + return Ok(true); + } + if name.contains(".spdx.") || name.ends_with(".spdx") { + return Ok(true); + } + } + if rel.extension().map(|e| e == "license").unwrap_or(false) { + return Ok(true); + } + // VCS internals, root .reuse/, Meson subprojects (separate projects). + if rel + .components() + .any(|c| matches!(c, Component::Normal(n) if n == ".git")) + || rel.file_name().map(|n| n == ".git").unwrap_or(false) + { + return Ok(true); + } + if rel != Path::new(".reuse") && rel.starts_with(".reuse") { + return Ok(true); + } + if rel != Path::new("subprojects") && rel.starts_with("subprojects") { + return Ok(true); + } + // Symlinks and zero-byte files need a stat/read from the snapshot. + match snapshot { + Snapshot::Worktree { root } => { + let abs = root.join(rel); + match std::fs::symlink_metadata(&abs) { + Ok(meta) => { + if meta.file_type().is_symlink() { + return Ok(true); + } + if meta.file_type().is_file() && meta.len() == 0 { + return Ok(true); + } + Ok(false) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(_) => Ok(false), + } + } + Snapshot::Index(index) => { + let Some(entry) = index.entries.get(rel) else { + return Ok(false); + }; + if !entry.is_regular_file() { + return Ok(true); + } + match index.read(rel)? { + Some(bytes) => Ok(bytes.is_empty()), + None => Ok(false), + } + } + } +} + +/// `COPYING`, `LICENSE`, `LICENCE`, optionally followed by a dash/dot separator +/// plus metadata (`LICENSE-MIT`, `COPYING.GPL`, `LICENCE.md`). +fn is_license_filename(name: &str) -> bool { + for base in ["COPYING", "LICENSE", "LICENCE"] { + if name == base { + return true; + } + if let Some(rest) = name.strip_prefix(base) + && let Some(sep) = rest.chars().next() + && (sep == '-' || sep == '.') + && rest.len() > 1 + { + return true; + } + } + false +} + +/// Normalize an explicit file list to root-relative paths. /// -/// We set `hidden(false)` so dotfiles (`.github/`, `.gitignore`, …) are scanned for -/// headers, which also un-skips `.git/`. Expressing the skips as negated override globs -/// lets `ignore` prune `.git/` at the directory level (it never descends) and matches on -/// the crate's own normalized paths — so there is no manual separator handling to get -/// wrong on Windows. The globs MUST be `!`-negated: an un-negated override glob flips the -/// matcher into whitelist mode and would ignore everything else. -const ALWAYS_SKIP: &[&str] = &["!.git/", "!.licet-cache"]; - -/// Full gitignore-aware parallel-capable walk; returns repo-relative file paths. -fn walk_full_tree(root: &Path) -> Result> { +/// Inputs are relative to the invocation cwd (or absolute); `.`/`..` are +/// resolved lexically without touching the filesystem (so symlinks never +/// change file identity). Paths outside the root and nonregular inputs +/// (directories, FIFOs, …) are usage errors; symlinks are kept and marked +/// REUSE-ignored later, identically regardless of list order. +fn normalize_file_list(root: &Path, cwd: &Path, files: &[PathBuf]) -> Result> { + let mut out = Vec::with_capacity(files.len()); + for f in files { + let abs = if f.is_absolute() { + lexical_normalize(f) + } else { + lexical_normalize(&cwd.join(f)) + }; + let rel = abs + .strip_prefix(root) + .map(|p| p.to_path_buf()) + .map_err(|_| { + LicetError::Config(format!( + "file {} is outside the project root {}", + f.display(), + root.display() + )) + })?; + if rel.as_os_str().is_empty() { + return Err(LicetError::Config( + "file selection names the project root itself".to_string(), + )); + } + match std::fs::symlink_metadata(&abs) { + Ok(meta) => { + let ft = meta.file_type(); + if ft.is_symlink() || ft.is_file() { + // Symlinks are marked REUSE-ignored downstream; never resolved here. + } else { + return Err(LicetError::Config(format!( + "file {} is not a regular file", + f.display() + ))); + } + } + // Missing inputs stay in the set and classify from absence (Unreadable). + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return Err(LicetError::Config(format!( + "cannot stat file {}: {e}", + f.display() + ))); + } + } + out.push(rel); + } + out.sort(); + out.dedup(); + Ok(out) +} + +/// Lexically normalize `.`/`..` without resolving symlinks or touching the +/// filesystem. +fn lexical_normalize(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for comp in path.components() { + match comp { + Component::CurDir => {} + Component::ParentDir => { + if !out.pop() { + out.push(".."); + } + } + other => out.push(other.as_os_str()), + } + } + if out.as_os_str().is_empty() { + out.push("."); + } + out +} + +/// VCS internals excluded from the worktree walk as `ignore` overrides. +/// (Scans create no files of their own, so nothing else needs skipping.) +const ALWAYS_SKIP: &[&str] = &["!.git/"]; + +/// Nonignored filesystem walk; returns root-relative file paths. Traversal +/// errors surface instead of silently skipping entries (F13). +fn walk_worktree(root: &Path) -> Result> { let mut ob = OverrideBuilder::new(root); for glob in ALWAYS_SKIP { ob.add(glob) @@ -147,11 +598,16 @@ fn walk_full_tree(root: &Path) -> Result> { .git_global(true) .parents(true) .overrides(overrides) + .follow_links(false) .build(); for entry in walker { let entry = match entry { Ok(e) => e, - Err(_) => continue, + Err(e) => { + return Err(LicetError::Io(std::io::Error::other(format!( + "directory traversal failed: {e}" + )))); + } }; if entry.file_type().map(|t| t.is_file()).unwrap_or(false) && let Ok(rel) = entry.path().strip_prefix(root) @@ -162,52 +618,18 @@ fn walk_full_tree(root: &Path) -> Result> { Ok(paths) } -/// Normalize an explicit file list to repo-relative paths. -fn normalize_file_list(root: &Path, files: &[PathBuf]) -> Vec { - files - .iter() - .map(|f| { - if f.is_absolute() { - f.strip_prefix(root).unwrap_or(f).to_path_buf() - } else { - // May already be repo-relative, or relative to cwd. - let abs = std::env::current_dir() - .unwrap_or_else(|_| root.to_path_buf()) - .join(f); - abs.strip_prefix(root) - .map(|p| p.to_path_buf()) - .unwrap_or_else(|_| f.clone()) - } - }) - .collect() -} - -/// Run a `git` selection command and parse `--name-only` output into repo-relative paths. -/// -/// Selection is a cold, small-N operation (hook/CI subset); the full-tree hot path stays -/// pure-Rust via `ignore`. Diff computation uses git directly for fidelity with the user's -/// exact staged/changed semantics. -fn git_subset(root: &Path, args: &[&str]) -> Result> { - let output = std::process::Command::new("git") - .arg("-C") - .arg(root) - .args(args) - .output() - .map_err(|e| LicetError::Git(format!("failed to run git: {e}")))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(LicetError::Git(format!( - "git {} failed: {}", - args.join(" "), - stderr.trim() - ))); +/// Build the exclusion matcher from config `[exclude]` glob patterns. +/// REUSE-level ignores live in [`is_reuse_ignored`], not here. +pub fn build_excludes(patterns: &[String]) -> Result { + let mut builder = globset::GlobSetBuilder::new(); + for p in patterns { + let glob = globset::Glob::new(p) + .map_err(|e| LicetError::Config(format!("invalid exclude glob `{p}`: {e}")))?; + builder.add(glob); } - let paths = String::from_utf8_lossy(&output.stdout) - .lines() - .filter(|l| !l.trim().is_empty()) - .map(PathBuf::from) - .collect(); - Ok(paths) + builder + .build() + .map_err(|e| LicetError::Internal(format!("glob build: {e}"))) } #[cfg(test)] @@ -215,24 +637,43 @@ mod tests { use super::*; #[test] - fn excludes_match() { - let set = build_excludes(&["vendor/**".to_string(), "*.lock".to_string()]).unwrap(); - assert!(set.is_match("vendor/x.rs")); - assert!(set.is_match("Cargo.lock")); - assert!(!set.is_match("src/lib.rs")); + fn lexical_normalize_keeps_identity() { + assert_eq!( + lexical_normalize(Path::new("./src/../src/x.rs")), + PathBuf::from("src/x.rs") + ); + assert_eq!( + lexical_normalize(Path::new("/r/a/../../b")), + PathBuf::from("/b") + ); } #[test] - fn walk_skips_git_internals_and_cache_keeps_dotfiles() { - // `hidden(false)` un-skips `.git/`; the override must prune it (and `.licet-cache`) - // while still scanning ordinary dotfiles like `.gitignore`. Regression guard for - // the Windows leak where a manual `.git/` string filter missed `\`-separated paths. + fn license_filename_variants() { + // Spec shape: base plus a dash/dot separator with metadata. + for name in [ + "COPYING", + "LICENSE", + "LICENCE", + "LICENSE-MIT", + "COPYING.GPL", + "LICENCE.md", + "LICENSE.Apache-2.0.txt", + "LICENSE.MIT.bak.extra", + ] { + assert!(is_license_filename(name), "{name}"); + } + for name in ["LICENSES", "licensed.rs", "UNLICENSE", "LICENSE_"] { + assert!(!is_license_filename(name), "{name}"); + } + } + + #[test] + fn walk_skips_git_internals_but_reports_errors() { let dir = tempfile::TempDir::new().unwrap(); let root = dir.path(); for (rel, body) in [ (".git/config", "x"), - (".git/objects/ab/cdef", "x"), - (".licet-cache", "x"), (".gitignore", "target\n"), ("src/lib.rs", "fn f() {}\n"), ] { @@ -240,17 +681,19 @@ mod tests { std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(p, body).unwrap(); } - - let mut found: Vec = walk_full_tree(root) - .unwrap() - .iter() - .map(|p| p.to_string_lossy().replace('\\', "/")) - .collect(); + let mut found = walk_worktree(root).unwrap(); found.sort(); - assert_eq!( found, - vec![".gitignore".to_string(), "src/lib.rs".to_string()] + vec![PathBuf::from(".gitignore"), PathBuf::from("src/lib.rs")] ); } + + #[test] + fn excludes_match() { + let set = build_excludes(&["vendor/**".to_string(), "*.lock".to_string()]).unwrap(); + assert!(set.is_match("vendor/x.rs")); + assert!(set.is_match("Cargo.lock")); + assert!(!set.is_match("src/lib.rs")); + } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index b3ddd62..07d1d9e 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -11,16 +11,45 @@ use tempfile::TempDir; /// A throwaway git repository populated by the test. pub struct Fixture { pub dir: TempDir, + /// Empty hooks directory, kept outside the repo so `git add -A` in + /// [`Fixture::commit`] never sweeps it into a fixture commit. + _hooks: TempDir, } impl Fixture { - /// Create an initialized git repo. + /// Create an initialized git repo, isolated from ambient Git identity, + /// commit signing, and repository hooks (audit §Evidence: an inherited + /// signing agent once failed an unrelated fixture run). pub fn new() -> Self { let dir = TempDir::new().expect("tempdir"); + let hooks = TempDir::new().expect("hooks tempdir"); run_git(dir.path(), &["init", "-q"]); run_git(dir.path(), &["config", "user.email", "t@t.co"]); run_git(dir.path(), &["config", "user.name", "Test"]); - Fixture { dir } + run_git(dir.path(), &["config", "commit.gpgsign", "false"]); + run_git(dir.path(), &["config", "tag.gpgsign", "false"]); + run_git( + dir.path(), + &["config", "core.hooksPath", hooks.path().to_str().unwrap()], + ); + Fixture { dir, _hooks: hooks } + } + + /// Run a checked `git` command in the fixture repo; panics with stderr context + /// on failure. Returns stdout bytes (lossless for non-UTF-8 paths). + pub fn git(&self, args: &[&str]) -> Vec { + let out = Command::new("git") + .current_dir(self.dir.path()) + .args(args) + .output() + .expect("run git"); + assert!( + out.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&out.stderr) + ); + out.stdout } pub fn path(&self) -> &Path { @@ -42,6 +71,17 @@ impl Fixture { self.write("license.toml", toml) } + /// Write bundled `LICENSES/.txt` texts (policy `check` requires the + /// texts referenced by its selected files). + pub fn texts(&self, ids: &[&str]) -> &Self { + for id in ids { + let text = + licet::spdx::bundled_text(id).unwrap_or_else(|| panic!("no bundled text for {id}")); + self.write(&format!("LICENSES/{id}.txt"), text); + } + self + } + /// Stage and commit everything. pub fn commit(&self, msg: &str) -> &Self { run_git(self.dir.path(), &["add", "-A"]); @@ -66,6 +106,14 @@ impl Fixture { cmd.current_dir(self.dir.path()); cmd } + + /// A `licet` command invoked from a subdirectory of the repo (path + /// arguments resolve against this cwd, the project root is discovered). + pub fn licet_in(&self, subdir: &str) -> Command { + let mut cmd = Command::new(cargo_bin("licet")); + cmd.current_dir(self.dir.path().join(subdir)); + cmd + } } fn run_git(dir: &Path, args: &[&str]) { diff --git a/tests/determinism.rs b/tests/determinism.rs index 2c6e932..d19bb05 100644 --- a/tests/determinism.rs +++ b/tests/determinism.rs @@ -30,21 +30,29 @@ fn mixed_repo() -> Fixture { #[test] fn identical_inputs_produce_byte_identical_reports() { let f = mixed_repo(); - let run = || { + let run = |extra: &[&str]| { let out = f .licet() - .args(["check", "--no-cache", "--format", "json"]) + .args(["check", "--format", "json"]) + .args(extra) .output() .unwrap(); (out.status.code(), String::from_utf8(out.stdout).unwrap()) }; - let (code1, json1) = run(); - let (code2, json2) = run(); + let (code1, json1) = run(&[]); + let (code2, json2) = run(&[]); assert_eq!(code1, code2, "exit code must be stable"); assert_eq!( json1, json2, "JSON report must be byte-identical across runs" ); + // The deprecated flags are true no-ops: same bytes out, no files created. + let (code3, json3) = run(&["--no-cache"]); + assert_eq!((code1, json1), (code3, json3)); + assert!( + !f.path().join(".git/licet-cache").exists(), + "stateless scans must not create cache files" + ); } #[test] @@ -67,30 +75,42 @@ fn file_ordering_is_lexicographically_sorted() { assert_eq!(paths, sorted, "files must be emitted in sorted order"); } +/// Output equality cannot prove no subprocess ran; a call assertion can: even +/// with `--allow-network` and a missing standard text, `lint` only diagnoses +/// and never spawns `curl` (Unix-only: the shim needs an executable bit). +#[cfg(unix)] #[test] -fn allow_network_flag_does_not_change_offline_behavior() { - // A repo whose declared license is a non-bundled SPDX id: with or without - // --allow-network, `lint` runs offline and produces the same posture/exit code. +fn lint_never_invokes_curl_even_with_allow_network() { + use std::os::unix::fs::PermissionsExt; let f = Fixture::new(); f.config("[default]\nlicense=\"MIT\"\n") - .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .write("a.rs", "// SPDX-License-Identifier: Apache-1.0\nfn a(){}\n") .commit("init"); - let offline = f - .licet() - .args(["lint", "--format", "json"]) - .output() - .unwrap(); - let allowed = f + let shim = tempfile::tempdir().unwrap(); + std::fs::write( + shim.path().join("curl"), + "#!/bin/sh\ntouch \"$FAKE_MARKER\"\nexit 1\n", + ) + .unwrap(); + std::fs::set_permissions( + shim.path().join("curl"), + std::fs::Permissions::from_mode(0o755), + ) + .unwrap(); + let marker = shim.path().join("invoked"); + let mut paths = vec![shim.path().to_path_buf()]; + paths.extend(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )); + let out = f .licet() + .env("PATH", std::env::join_paths(paths).unwrap()) + .env("FAKE_MARKER", &marker) .args(["lint", "--allow-network", "--format", "json"]) .output() .unwrap(); - assert_eq!(offline.status.code(), allowed.status.code()); - assert_eq!( - String::from_utf8(offline.stdout).unwrap(), - String::from_utf8(allowed.stdout).unwrap(), - "JSON lint posture must be identical regardless of --allow-network (no fetch occurs)" - ); + assert_eq!(out.status.code(), Some(1), "missing text fails the gate"); + assert!(!marker.exists(), "lint must never invoke curl"); } // REUSE-IgnoreEnd diff --git a/tests/fixtures/table/sample.js b/tests/fixtures/table/sample.js new file mode 100644 index 0000000..337669d --- /dev/null +++ b/tests/fixtures/table/sample.js @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Acme + +export function hello() { + return "hello"; +} diff --git a/tests/fixtures/table/sample.jsx b/tests/fixtures/table/sample.jsx new file mode 100644 index 0000000..7e7d298 --- /dev/null +++ b/tests/fixtures/table/sample.jsx @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Acme + +export function Hello() { + return

hello

; +} diff --git a/tests/fixtures/table/sample.py b/tests/fixtures/table/sample.py new file mode 100644 index 0000000..7279b6d --- /dev/null +++ b/tests/fixtures/table/sample.py @@ -0,0 +1,5 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 Acme + +def hello(): + return "hello" diff --git a/tests/fixtures/table/sample.rb b/tests/fixtures/table/sample.rb new file mode 100644 index 0000000..b801dc2 --- /dev/null +++ b/tests/fixtures/table/sample.rb @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 Acme + +def hello + "hello" +end diff --git a/tests/fixtures/table/sample.sh b/tests/fixtures/table/sample.sh new file mode 100644 index 0000000..e1e3695 --- /dev/null +++ b/tests/fixtures/table/sample.sh @@ -0,0 +1,5 @@ +#!/bin/sh +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 Acme + +echo hello diff --git a/tests/perf.rs b/tests/perf.rs index 223dde4..0e93eb3 100644 --- a/tests/perf.rs +++ b/tests/perf.rs @@ -21,7 +21,7 @@ fn scans_a_few_thousand_files_quickly() { ); } - // Cold run (no cache). + // Stateless run (scans never cache; the flag is a deprecated no-op). let t0 = Instant::now(); let out = f.licet().args(["check", "--no-cache"]).output().unwrap(); let cold = t0.elapsed(); @@ -34,4 +34,48 @@ fn scans_a_few_thousand_files_quickly() { ); eprintln!("cold scan of {n} files: {cold:?}"); } + +/// Audit F16: SPDX parsing once did quadratic work on long `LicenseRef` +/// suffixes (about 34 ms at 1 KiB, 424 ms at 4 KiB, past a 4 s timeout at +/// 16 KiB in a debug build). After AST normalization the work must be +/// ~linear: increasing sizes with a generous hang guard only — no +/// microsecond assertions in ordinary tests. +#[test] +fn long_licenseref_scales_without_quadratic_blowup() { + fn check_seconds_for_id_len(len: usize) -> f64 { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write( + "big.rs", + &format!( + "// SPDX-License-Identifier: LicenseRef-{}\nfn x(){{}}\n", + "A".repeat(len) + ), + ) + .commit("init"); + let t0 = Instant::now(); + let out = f + .licet() + .args(["check", "--files", "big.rs"]) + .output() + .unwrap(); + // Completes (wrong license, missing custom text) rather than hanging. + assert_eq!(out.status.code(), Some(1)); + t0.elapsed().as_secs_f64() + } + + let t4 = check_seconds_for_id_len(4_000); + let t16 = check_seconds_for_id_len(16_000); + eprintln!("licenseref 4k: {t4:.3}s, 16k: {t16:.3}s"); + assert!( + t16 < 20.0, + "16 KiB LicenseRef took {t16:.1}s (hang guard; the old parser exceeded 4 s)" + ); + // Linear work quadruples for 4x input; quadratic would be ~16x. The 8x + // bar plus a 50 ms noise floor leaves wide margin on shared hardware. + assert!( + t16 < 8.0 * t4.max(0.05), + "16 KiB took {t16:.3}s vs 4 KiB {t4:.3}s — worse than linear" + ); +} // REUSE-IgnoreEnd diff --git a/tests/report_contract.rs b/tests/report_contract.rs new file mode 100644 index 0000000..134d4a2 --- /dev/null +++ b/tests/report_contract.rs @@ -0,0 +1,160 @@ +//! Report contract — `report.schema.json` stays valid JSON and real command +//! output carries every field the contract requires (FR-004, FR-021; task 7f). +//! +//! A prior edit left the schema with a misplaced brace: it parsed only after +//! shedding `diagnostics`/`writes`/`license_texts` out of `properties`. This +//! suite pins both the schema structure and a real `check` report against it. + +mod common; +use common::Fixture; + +use std::path::PathBuf; + +fn contract_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("specs/001-declarative-license-headers/contracts/report.schema.json") +} + +/// The contract must be well-formed JSON with the v2 shape: `diagnostics`, +/// `writes`, and `license_texts` live inside `properties`, not beside it. +#[test] +fn report_schema_is_valid_v2() { + let text = std::fs::read_to_string(contract_path()).expect("schema file must exist"); + let schema: serde_json::Value = + serde_json::from_str(&text).expect("report.schema.json must be valid JSON"); + assert_eq!(schema["properties"]["version"]["const"], 2); + for key in ["diagnostics", "writes", "license_texts"] { + assert!( + schema["properties"].get(key).is_some(), + "`{key}` must live inside `properties`" + ); + assert!( + schema.get(key).is_none(), + "`{key}` must not be a sibling of `properties`" + ); + } + for key in ["version", "command", "summary", "files"] { + assert!( + schema["required"] + .as_array() + .unwrap() + .contains(&serde_json::Value::String(key.to_string())), + "top-level `{key}` must be required" + ); + } +} + +/// Every command's `--format json` stdout is exactly one parseable document: +/// no progress prefix/suffix may pollute the stream (task 9). +#[test] +fn every_command_json_stdout_is_one_document() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .write("b.py", "x = 1\n") + .texts(&["MIT"]) + .commit("init"); + + // check (gate failure still yields one document). + let out = f + .licet() + .args(["check", "--format", "json"]) + .output() + .unwrap(); + let check_report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(check_report["command"], "check"); + + // apply dry-run predicts without writing. + let out = f + .licet() + .args(["apply", "--dry-run", "--format", "json"]) + .output() + .unwrap(); + let apply_report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(apply_report["command"], "apply"); + + // lint over actual metadata. + let out = f + .licet() + .args(["lint", "--format", "json"]) + .output() + .unwrap(); + let lint_report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(lint_report["command"], "lint"); + + // init to a fresh output. + let out = f + .licet() + .args(["init", "--format", "json", "--output", "gen.toml"]) + .output() + .unwrap(); + let init_report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(init_report["command"], "init"); + + // add-license materializes one missing bundled text. + let out = f + .licet() + .args(["add-license", "Apache-2.0", "--format", "json"]) + .output() + .unwrap(); + let add_report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(add_report["command"], "add-license"); + + for report in [ + &check_report, + &apply_report, + &lint_report, + &init_report, + &add_report, + ] { + assert_eq!(report["version"], 2, "{report}"); + } +} + +/// A real `check --format json` report satisfies the contract's required fields. +#[test] +fn check_json_report_satisfies_required_contract_fields() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n") + .write("a_good.py", "# SPDX-License-Identifier: MIT\nx=1\n") + .write("b_wrong.py", "# SPDX-License-Identifier: Apache-2.0\ny=2\n") + .texts(&["MIT", "Apache-2.0"]) + .commit("init"); + + let out = f + .licet() + .args(["check", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + // JSON stdout is exactly one serialized document. + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(report["version"], 2); + assert_eq!(report["command"], "check"); + assert_eq!(report["summary"]["pass"], false); + assert_eq!(report["summary"]["complete"], true); + assert!(report["summary"]["counts"].is_object()); + let files = report["files"].as_array().unwrap(); + for entry in files { + assert!(entry.get("path").is_some()); + assert!(entry.get("drift").is_some()); + } + let drift_of = |name: &str| { + files + .iter() + .find(|e| e["path"] == name) + .unwrap_or_else(|| panic!("{name} must be reported"))["drift"] + .as_str() + .unwrap() + .to_string() + }; + assert_eq!(drift_of("a_good.py"), "compliant"); + assert_eq!(drift_of("b_wrong.py"), "wrong_license"); + // Diagnostics and writes keys exist in the shape even when empty they may + // be omitted; when present they must be arrays. + for key in ["diagnostics", "writes"] { + if let Some(v) = report.get(key) { + assert!(v.is_array(), "`{key}` must be an array"); + } + } +} diff --git a/tests/reuse_differential.rs b/tests/reuse_differential.rs new file mode 100644 index 0000000..1c35870 --- /dev/null +++ b/tests/reuse_differential.rs @@ -0,0 +1,784 @@ +//! REUSE differential conformance (task 11; finding F17). +//! +//! `licet lint` against the pinned reference tool (`reuse[charset-normalizer]` +//! 6.2.0, implementing REUSE 3.3) over a compact cross-tool fixture matrix. +//! Every fixture compares semantic fields — covered paths, effective +//! references, pass/fail — never human-output strings. Mismatches are +//! recorded as documented fixtures, never blessed silently. +//! +//! The comparator runs against fully committed fixtures (tracked files), with +//! one dedicated untracked-file fixture. Absence or failure of the comparator +//! is fatal under `LICET_REQUIRE_REUSE=1`; locally a missing comparator +//! prints an explicit skip. Override the binary with `LICET_REUSE_BIN`. +//! +//! Documented divergences (same gate, different vocabulary/provenance): +//! - Deprecated SPDX ids (e.g. `GPL-2.0`): both tools fail, but licet rejects +//! the value as invalid (never inventoried, text reported unused) while the +//! reference reports it as deprecated-but-used. Licet's strictness is the +//! normative reading (deprecated identifiers must not be used); no false +//! pass is possible on either side. +//! - Undecodable license texts: licet reports incomplete validation +//! (`unsupported_encoding`); the reference lists the entry as unused. Both +//! fail; licet additionally refuses to claim anything about the bytes. +// REUSE-IgnoreStart — SPDX tags below are test fixtures, not this file's licensing. + +mod common; +use common::Fixture; + +use std::collections::BTreeSet; +use std::path::PathBuf; +use std::process::Command; + +/// Makes a missing/broken comparator fatal (set in CI). +const REQUIRE_ENV: &str = "LICET_REQUIRE_REUSE"; +/// Override the comparator binary location. +const BIN_ENV: &str = "LICET_REUSE_BIN"; + +struct Comparator { + bin: PathBuf, +} + +fn comparator() -> Option { + let bin = std::env::var(BIN_ENV) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("reuse")); + match Command::new(&bin).arg("--version").output() { + Ok(o) if o.status.success() => Some(Comparator { bin }), + _ => { + let msg = format!( + "skipping differential conformance: no working `{}` comparator on PATH (set {BIN_ENV} or install 'reuse[charset-normalizer]==6.2.0')", + bin.display() + ); + if std::env::var(REQUIRE_ENV).as_deref() == Ok("1") { + panic!("{msg}; {REQUIRE_ENV}=1 makes this fatal"); + } + eprintln!("{msg}"); + None + } + } +} + +fn strset(v: &serde_json::Value) -> BTreeSet { + v.as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|s| s.as_str().map(str::to_string)) + .collect() +} + +/// One fixture evaluated by both tools. +struct Comparison { + licet_exit: i32, + licet_pass: bool, + licet_coverage: BTreeSet, + licet_referenced: BTreeSet, + licet_missing: BTreeSet, + licet_unused: BTreeSet, + licet_noext: BTreeSet, + licet_unrec_stems: BTreeSet, + /// Files failing with a license problem (`missing_license` or + /// `invalid_license` diagnostics). + licet_license_files: BTreeSet, + licet_copyright_files: BTreeSet, + reuse_exit: i32, + reuse_compliant: bool, + reuse_spec: String, + reuse_tool: String, + reuse_files: BTreeSet, + /// Union of every per-file SPDX expression value. + reuse_effective: BTreeSet, + reuse_missing_licensing: BTreeSet, + reuse_missing_copyright: BTreeSet, + reuse_missing_licenses: BTreeSet, + reuse_unused: BTreeSet, + reuse_noext: BTreeSet, + reuse_bad: BTreeSet, + reuse_deprecated: BTreeSet, + reuse_read_errors: BTreeSet, +} + +fn diag_paths(report: &serde_json::Value, code: &str) -> BTreeSet { + report["diagnostics"] + .as_array() + .map(|ds| { + ds.iter() + .filter(|d| d["code"] == code) + .filter_map(|d| d["path"].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +fn compare(f: &Fixture, comp: &Comparator) -> Comparison { + let lout = f + .licet() + .args(["lint", "--format", "json"]) + .output() + .unwrap(); + let licet_exit = lout.status.code().unwrap_or(-1); + let licet: serde_json::Value = serde_json::from_slice(&lout.stdout).unwrap(); + + let out = Command::new(&comp.bin) + .args(["lint", "--json"]) + .current_dir(f.path()) + .output() + .unwrap(); + assert!( + out.status.success() || out.status.code() == Some(1), + "reference lint runs, got {:?}: {}", + out.status.code(), + String::from_utf8_lossy(&out.stderr) + ); + let reuse: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + + let licet_diag = |codes: &[&str]| { + codes + .iter() + .flat_map(|c| diag_paths(&licet, c)) + .collect::>() + }; + let nc = &reuse["non_compliant"]; + let reuse_files: BTreeSet = reuse["files"] + .as_array() + .unwrap() + .iter() + .filter_map(|e| e["path"].as_str().map(str::to_string)) + .collect(); + let reuse_effective: BTreeSet = reuse["files"] + .as_array() + .unwrap() + .iter() + .flat_map(|e| { + e["spdx_expressions"] + .as_array() + .cloned() + .unwrap_or_default() + }) + .filter_map(|x| x["value"].as_str().map(str::to_string)) + .collect(); + + Comparison { + licet_exit, + licet_pass: licet["summary"]["pass"].as_bool().unwrap(), + licet_coverage: strset(&licet["coverage"]), + licet_referenced: strset(&licet["license_texts"]["referenced"]), + licet_missing: strset(&licet["license_texts"]["missing"]), + licet_unused: strset( + &licet["license_texts"] + .get("unused") + .cloned() + .unwrap_or_default(), + ), + licet_noext: strset( + &licet["license_texts"] + .get("missing_extension") + .cloned() + .unwrap_or_default(), + ), + licet_unrec_stems: licet["license_texts"] + .get("unrecognized") + .map(|v| { + v.as_array() + .cloned() + .unwrap_or_default() + .iter() + .filter_map(|p| { + p.as_str().and_then(|s| { + std::path::Path::new(s) + .file_stem() + .and_then(|n| n.to_str().map(str::to_string)) + }) + }) + .collect() + }) + .unwrap_or_default(), + licet_license_files: licet_diag(&["missing_license", "invalid_license"]), + licet_copyright_files: licet_diag(&["missing_copyright"]), + reuse_exit: out.status.code().unwrap_or(-1), + reuse_compliant: reuse["summary"]["compliant"].as_bool().unwrap(), + reuse_spec: reuse["reuse_spec_version"] + .as_str() + .unwrap_or("?") + .to_string(), + reuse_tool: reuse["reuse_tool_version"] + .as_str() + .unwrap_or("?") + .to_string(), + reuse_files, + reuse_effective, + reuse_missing_licensing: strset(&nc["missing_licensing_info"]), + reuse_missing_copyright: strset(&nc["missing_copyright_info"]), + reuse_missing_licenses: strset(&nc["missing_licenses"]), + reuse_unused: strset(&nc["unused_licenses"]), + reuse_noext: strset(&nc["licenses_without_extension"]), + reuse_bad: strset(&nc["bad_licenses"]), + reuse_deprecated: strset(&nc["deprecated_licenses"]), + reuse_read_errors: strset(&nc["read_errors"]), + } +} + +impl Comparison { + /// The gate agrees: same exit and same pass/fail. + fn assert_gate(&self) { + assert_eq!(self.licet_exit, self.reuse_exit, "exit codes agree"); + assert_eq!(self.licet_pass, self.reuse_compliant, "pass/fail agrees"); + } + + /// The same files are covered. + fn assert_coverage(&self) { + assert_eq!(self.licet_coverage, self.reuse_files, "covered paths agree"); + } + + /// The same effective license references are inventoried. + fn assert_effective_refs(&self) { + assert_eq!( + self.licet_referenced, self.reuse_effective, + "effective references agree" + ); + } + + /// Missing / unused / extensionless text sets agree. + fn assert_text_sets(&self) { + assert_eq!( + self.licet_missing, self.reuse_missing_licenses, + "missing texts agree" + ); + assert_eq!(self.licet_unused, self.reuse_unused, "unused texts agree"); + assert_eq!( + self.licet_noext, self.reuse_noext, + "missing extensions agree" + ); + } + + /// Per-file license/copyright failure attribution agrees. + fn assert_file_attribution(&self) { + assert_eq!( + self.licet_license_files, self.reuse_missing_licensing, + "files missing licensing agree" + ); + assert_eq!( + self.licet_copyright_files, self.reuse_missing_copyright, + "files missing copyright agree" + ); + } + + /// Everything agrees: gate, coverage, references, texts, attribution. + fn assert_full_agreement(&self) { + self.assert_gate(); + self.assert_coverage(); + self.assert_effective_refs(); + self.assert_text_sets(); + self.assert_file_attribution(); + } +} + +/// Records the pinned comparator's versions in the test output and pins the +/// REUSE specification target (3.3); the tool version itself is evidence only. +#[test] +fn comparator_versions_are_recorded() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 T\nfn a(){}\n", + ) + .texts(&["MIT"]) + .commit("init"); + let c = compare(&f, &comp); + eprintln!( + "differential comparator: reuse tool {}, REUSE spec {}", + c.reuse_tool, c.reuse_spec + ); + assert_eq!(c.reuse_spec, "3.3", "the pinned tool must target REUSE 3.3"); + assert!(c.reuse_bad.is_empty(), "no bad licenses on a clean fixture"); + assert!( + c.reuse_read_errors.is_empty(), + "no read errors on a clean fixture" + ); + c.assert_full_agreement(); +} + +/// Write a bundled license text into the fixture. +fn bundled<'a>(f: &'a Fixture, id: &str) -> &'a Fixture { + f.write( + &format!("LICENSES/{id}.txt"), + licet::spdx::bundled_text(id).unwrap_or_else(|| panic!("no bundled text for {id}")), + ) +} + +/// Audited disagreement 1 (F08): a header without copyright fails on both. +#[test] +fn diff_header_without_copyright_fails_both() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n"); + bundled(&f, "MIT"); + f.commit("init"); + let c = compare(&f, &comp); + assert_eq!((c.licet_exit, c.reuse_exit), (1, 1)); + c.assert_full_agreement(); +} + +/// Audited disagreement 2 (F08): two header licenses, one text — both fail. +#[test] +fn diff_two_licenses_one_text_fails_both() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-License-Identifier: Apache-2.0\n// SPDX-FileCopyrightText: 2026 T\nfn a(){}\n", + ); + bundled(&f, "MIT"); + f.commit("init"); + let c = compare(&f, &comp); + assert_eq!(c.licet_missing, BTreeSet::from(["Apache-2.0".to_string()])); + c.assert_full_agreement(); +} + +/// Audited disagreement 3 (F08): a late-snippet license is inventoried and +/// required by both — never silently dropped. +#[test] +fn diff_late_snippet_license_required_by_both() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + let mut body = + String::from("// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 T\n"); + while body.len() < 10 * 1024 { + body.push_str("// filler to push the snippet late\n"); + } + body.push_str("// SPDX-SnippetBegin: s1\n// SPDX-License-Identifier: Apache-2.0\n// SPDX-SnippetEnd: s1\n"); + f.write("a.rs", &body); + bundled(&f, "MIT"); + f.commit("init"); + let c = compare(&f, &comp); + assert!(c.licet_referenced.contains("Apache-2.0")); + assert_eq!((c.licet_exit, c.reuse_exit), (1, 1)); + c.assert_full_agreement(); +} + +/// Audited disagreement 4 (F08): a deep headerless file under an annotation +/// fails copyright and license on both. +#[test] +fn diff_deep_annotated_file_without_metadata_fails_both() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "REUSE.toml", + "version = 1\n[[annotations]]\npath = \"src/*\"\nSPDX-License-Identifier = \"MIT\"\n", + ) + .write("src/deep/a.rs", "fn a(){}\n"); + bundled(&f, "MIT"); + f.commit("init"); + let c = compare(&f, &comp); + assert_eq!((c.licet_exit, c.reuse_exit), (1, 1)); + c.assert_full_agreement(); +} + +/// Audited disagreement 5 (F08): a valid license array with both texts +/// passes on both — one table must not discard the other. +#[test] +fn diff_license_array_with_both_texts_passes_both() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "REUSE.toml", + "version = 1\n[[annotations]]\npath = \"a.rs\"\nSPDX-License-Identifier = [\"MIT\", \"Apache-2.0\"]\nSPDX-FileCopyrightText = \"2026 T\"\n", + ) + .write("a.rs", "fn a(){}\n"); + bundled(&f, "MIT"); + bundled(&f, "Apache-2.0"); + f.commit("init"); + let c = compare(&f, &comp); + c.assert_full_agreement(); + assert!(c.licet_pass && c.reuse_compliant); +} + +/// A snippet-only file carries its license and copyright in the snippet: the +/// reference tool flattens snippet notices into the file's info, and lint +/// counts them the same way (declared policy never does). +#[test] +fn diff_snippet_only_file_passes_both() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "s.rs", + "// SPDX-SnippetBegin: s1\n// SPDX-FileCopyrightText: 2026 Snip\n// SPDX-License-Identifier: Apache-2.0\n// SPDX-SnippetEnd: s1\nfn x(){}\n", + ); + bundled(&f, "Apache-2.0"); + f.commit("init"); + let c = compare(&f, &comp); + c.assert_full_agreement(); + assert!(c.licet_pass && c.reuse_compliant); +} + +/// Nested REUSE documents resolve root-first: the nearest `closest` license +/// wins (the file keeps its copyright fallback), so the root's MIT text is +/// unused on both sides and both fail for it. +#[test] +fn diff_nested_precedence_agrees() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "REUSE.toml", + "version = 1\n[[annotations]]\npath = \"sub/f.rs\"\nSPDX-License-Identifier = \"MIT\"\nSPDX-FileCopyrightText = \"2026 Root\"\n", + ) + .write( + "sub/REUSE.toml", + "version = 1\n[[annotations]]\npath = \"f.rs\"\nSPDX-License-Identifier = \"Apache-2.0\"\n", + ) + .write("sub/f.rs", "fn x(){}\n"); + bundled(&f, "MIT"); + bundled(&f, "Apache-2.0"); + f.commit("init"); + let c = compare(&f, &comp); + assert_eq!((c.licet_exit, c.reuse_exit), (1, 1)); + assert_eq!(c.licet_unused, BTreeSet::from(["MIT".to_string()])); + c.assert_full_agreement(); +} + +/// An `override` barrier suppresses the in-file header: both tools evaluate +/// the annotation's license — and both still fail the missing copyright. +#[test] +fn diff_override_barrier_agrees() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "REUSE.toml", + "version = 1\n[[annotations]]\npath = \"a.rs\"\nSPDX-License-Identifier = \"Apache-2.0\"\nprecedence = \"override\"\n", + ) + .write("a.rs", "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 T\nfn a(){}\n"); + bundled(&f, "MIT"); + bundled(&f, "Apache-2.0"); + f.commit("init"); + let c = compare(&f, &comp); + assert_eq!( + c.licet_referenced, + BTreeSet::from(["Apache-2.0".to_string()]) + ); + assert_eq!((c.licet_exit, c.reuse_exit), (1, 1)); + c.assert_full_agreement(); +} + +/// A binary asset covered by a sidecar passes on both. +#[test] +fn diff_sidecar_binary_passes_both() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "asset.bin.license", + "SPDX-License-Identifier: MIT\nSPDX-FileCopyrightText: 2026 T\n", + ); + bundled(&f, "MIT"); + std::fs::write(f.path().join("asset.bin"), [0x00, 0xFF, 0x89, 0x50]).unwrap(); + f.commit("init"); + let c = compare(&f, &comp); + c.assert_full_agreement(); + assert!(c.licet_pass && c.reuse_compliant); +} + +/// An extensionless license text satisfies presence on both, and both flag +/// its missing extension (REUSE 3.3 requires one); the unused text fails both. +#[test] +fn diff_extensionless_and_unused_texts_fail_both() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 T\nfn a(){}\n", + ) + .write("LICENSES/MIT", licet::spdx::bundled_text("MIT").unwrap()) + .write( + "LICENSES/Apache-2.0.txt", + licet::spdx::bundled_text("Apache-2.0").unwrap(), + ); + f.commit("init"); + let c = compare(&f, &comp); + assert_eq!((c.licet_exit, c.reuse_exit), (1, 1)); + c.assert_full_agreement(); +} + +/// Zero-byte files, symlinks, and SPDX documents are ignored by both. +#[test] +fn diff_ignored_files_agree() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 T\nfn a(){}\n", + ) + .write("empty.rs", "") + .write("doc.spdx", "SPDXVersion: SPDX-2.3\nPackageName: x\n"); + bundled(&f, "MIT"); + #[cfg(unix)] + std::os::unix::fs::symlink(f.path().join("a.rs"), f.path().join("link.rs")).unwrap(); + f.commit("init"); + let c = compare(&f, &comp); + assert!(!c.licet_coverage.contains("empty.rs")); + c.assert_full_agreement(); + assert!(c.licet_pass && c.reuse_compliant); +} + +/// Legacy DEP5 coverage aggregates with file-level metadata on both. +#[test] +fn diff_dep5_coverage_passes_both() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + ".reuse/dep5", + "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\nFiles: src/*\nCopyright: 2026 T\nLicense: MIT\n", + ) + .write("src/f.c", "fn x(){}\n"); + bundled(&f, "MIT"); + f.commit("init"); + let c = compare(&f, &comp); + c.assert_full_agreement(); + assert!(c.licet_pass && c.reuse_compliant); +} + +/// A custom LicenseRef with its text passes on both; without it both fail. +#[test] +fn diff_licenseref_text_present_and_missing() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: LicenseRef-Acme-1.0\n// SPDX-FileCopyrightText: 2026 T\nfn a(){}\n", + ) + .write("LICENSES/LicenseRef-Acme-1.0.txt", "Acme license text.\n"); + f.commit("init"); + let c = compare(&f, &comp); + c.assert_full_agreement(); + assert!(c.licet_pass && c.reuse_compliant); + + let g = Fixture::new(); + g.write( + "a.rs", + "// SPDX-License-Identifier: LicenseRef-Acme-1.0\n// SPDX-FileCopyrightText: 2026 T\nfn a(){}\n", + ); + g.commit("init"); + let c = compare(&g, &comp); + assert_eq!((c.licet_exit, c.reuse_exit), (1, 1)); + c.assert_full_agreement(); +} + +/// An invalid license expression fails on both with the file attributed. +#[test] +fn diff_invalid_expression_fails_both() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT OR\n// SPDX-FileCopyrightText: 2026 T\nfn a(){}\n", + ); + bundled(&f, "MIT"); + f.commit("init"); + let c = compare(&f, &comp); + assert_eq!((c.licet_exit, c.reuse_exit), (1, 1)); + c.assert_coverage(); + c.assert_file_attribution(); +} + +/// DOCUMENTED DIVERGENCE — deprecated SPDX ids: both tools fail, but licet +/// rejects the value as invalid (never inventoried, text unused) while the +/// reference reports it as deprecated-but-used. Same gate, same coverage. +#[test] +fn diff_deprecated_id_fails_both_with_documented_vocabulary() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: GPL-2.0\n// SPDX-FileCopyrightText: 2026 T\nfn a(){}\n", + ) + .write("LICENSES/GPL-2.0.txt", "Deprecated license text.\n"); + f.commit("init"); + let c = compare(&f, &comp); + assert_eq!((c.licet_exit, c.reuse_exit), (1, 1)); + c.assert_coverage(); + assert_eq!(c.reuse_deprecated, BTreeSet::from(["GPL-2.0".to_string()])); + assert!(diag_paths(&licet_report(&f), "invalid_license").contains("a.rs")); +} + +/// An unclosed snippet is treated as running to end-of-input by both. +#[test] +fn diff_unclosed_snippet_agrees() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-FileCopyrightText: 2026 T\n// SPDX-SnippetBegin: s1\n// SPDX-License-Identifier: Apache-2.0\nfn x(){}\n", + ); + bundled(&f, "Apache-2.0"); + f.commit("init"); + let c = compare(&f, &comp); + c.assert_full_agreement(); +} + +/// REUSE.toml and DEP5 together are mutually exclusive: both tools refuse +/// with exit 2 rather than guessing. +#[test] +fn diff_reuse_and_dep5_coexistence_refused_by_both() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 T\nfn a(){}\n", + ) + .write( + "REUSE.toml", + "version = 1\n[[annotations]]\npath = \"a.rs\"\nSPDX-License-Identifier = \"MIT\"\n", + ) + .write( + ".reuse/dep5", + "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n", + ); + bundled(&f, "MIT"); + f.commit("init"); + + let lout = f + .licet() + .args(["lint", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(lout.status.code(), Some(2)); + + let rout = Command::new(&comp.bin) + .args(["lint", "--json"]) + .current_dir(f.path()) + .output() + .unwrap(); + assert_eq!(rout.status.code(), Some(2)); +} + +/// A malformed REUSE document fails before any evaluation on both sides. +#[test] +fn diff_malformed_reuse_document_fails_both() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 T\nfn a(){}\n", + ) + .write("REUSE.toml", "version = 1\n[[annotations\n"); + bundled(&f, "MIT"); + f.commit("init"); + + let lout = f + .licet() + .args(["lint", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(lout.status.code(), Some(2)); + + let rout = Command::new(&comp.bin) + .args(["lint", "--json"]) + .current_dir(f.path()) + .output() + .unwrap(); + assert_ne!(rout.status.code(), Some(0)); +} + +/// Tracked-plus-untracked coverage agrees: an uncommitted file is evaluated +/// by both tools, not just the committed tree. +#[test] +fn diff_untracked_file_covered_by_both() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 T\nfn a(){}\n", + ); + bundled(&f, "MIT"); + f.commit("init"); + // Untracked after the commit: covered by lint on both sides. + f.write("u.rs", "// SPDX-License-Identifier: MIT\nfn u(){}\n"); + let c = compare(&f, &comp); + assert!(c.licet_coverage.contains("u.rs")); + assert_eq!((c.licet_exit, c.reuse_exit), (1, 1)); + c.assert_full_agreement(); +} + +/// An undecodable source file fails validation on both (never a pass). +#[test] +fn diff_unreadable_source_fails_both() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 T\nfn a(){}\n", + ); + bundled(&f, "MIT"); + std::fs::write(f.path().join("bin.rs"), [0xFF, 0xFE, 0x00]).unwrap(); + f.commit("init"); + let c = compare(&f, &comp); + assert_eq!((c.licet_exit, c.reuse_exit), (1, 1)); + c.assert_coverage(); + c.assert_file_attribution(); +} + +/// DOCUMENTED DIVERGENCE — undecodable license texts: licet reports +/// incomplete validation (`unsupported_encoding`) while the reference lists +/// the entry as unused. Both fail; licet refuses to claim anything about +/// bytes it cannot read. +#[test] +fn diff_unreadable_text_fails_both_with_documented_vocabulary() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 T\nfn a(){}\n", + ); + bundled(&f, "MIT"); + std::fs::write(f.path().join("LICENSES/Apache-2.0.txt"), [0xFF, 0xFE]).unwrap(); + f.commit("init"); + let c = compare(&f, &comp); + assert_eq!((c.licet_exit, c.reuse_exit), (1, 1)); + c.assert_coverage(); + assert!( + diag_paths(&licet_report(&f), "unsupported_encoding").contains("LICENSES/Apache-2.0.txt") + ); +} + +/// An unrecognized LICENSES entry fails both; names map by file stem. +#[test] +fn diff_unrecognized_entry_fails_both() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 T\nfn a(){}\n", + ) + .write("LICENSES/junk.txt", "hello\n"); + bundled(&f, "MIT"); + f.commit("init"); + let c = compare(&f, &comp); + assert_eq!((c.licet_exit, c.reuse_exit), (1, 1)); + assert_eq!(c.licet_unrec_stems, c.reuse_bad); +} + +/// An empty but correctly named text satisfies presence on both: neither +/// tool claims to prove legal correctness of file contents. +#[test] +fn diff_empty_text_accepted_by_both() { + let Some(comp) = comparator() else { return }; + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 T\nfn a(){}\n", + ) + .write("LICENSES/MIT.txt", ""); + f.commit("init"); + let c = compare(&f, &comp); + c.assert_full_agreement(); + assert!(c.licet_pass && c.reuse_compliant); +} + +/// Helper for single-diagnostic assertions: the licet lint JSON report. +fn licet_report(f: &Fixture) -> serde_json::Value { + let out = f + .licet() + .args(["lint", "--format", "json"]) + .output() + .unwrap(); + serde_json::from_slice(&out.stdout).unwrap() +} +// REUSE-IgnoreEnd diff --git a/tests/safety.rs b/tests/safety.rs index 5aa3aff..d105a40 100644 --- a/tests/safety.rs +++ b/tests/safety.rs @@ -27,8 +27,10 @@ fn apply_refuses_on_dirty_tree() { fn apply_allow_dirty_overrides() { let f = Fixture::new(); f.config("[default]\nlicense=\"MIT\"\n") - .write("a.rs", "fn a(){}\n"); - // Never committed → dirty, but --allow-dirty proceeds. + .write("a.rs", "fn a(){}\n") + .commit("init"); + // Dirty working tree, but --allow-dirty proceeds. + f.write("a.rs", "fn a(){}\n// edited\n"); let out = f.licet().args(["apply", "--allow-dirty"]).output().unwrap(); assert_ne!( out.status.code(), @@ -75,9 +77,9 @@ fn override_precedence_wins_over_header_with_source_override_warning() { "{}", String::from_utf8_lossy(&out.stdout) ); - let warnings = v["warnings"].as_array().cloned().unwrap_or_default(); + let diagnostics = v["diagnostics"].as_array().cloned().unwrap_or_default(); assert!( - warnings.iter().any(|w| w["kind"] == "source_override"), + diagnostics.iter().any(|w| w["code"] == "source_override"), "expected source_override warning: {}", String::from_utf8_lossy(&out.stdout) ); @@ -108,9 +110,9 @@ fn closest_precedence_default_lets_in_file_header_win() { "{}", String::from_utf8_lossy(&out.stdout) ); - let warnings = v["warnings"].as_array().cloned().unwrap_or_default(); + let diagnostics = v["diagnostics"].as_array().cloned().unwrap_or_default(); assert!( - !warnings.iter().any(|w| w["kind"] == "source_override"), + !diagnostics.iter().any(|w| w["code"] == "source_override"), "closest precedence must not emit source_override: {}", String::from_utf8_lossy(&out.stdout) ); @@ -130,4 +132,59 @@ fn line_endings_preserved_on_write() { "CRLF preserved: {content:?}" ); } +#[cfg(unix)] +#[test] +fn apply_does_not_follow_predictable_temp_symlink() { + use std::os::unix::fs::symlink; + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n") + .write("a.rs", "fn a() {}\n"); + let outside = tempfile::tempdir().unwrap(); + let sentinel = outside.path().join("sentinel"); + std::fs::write(&sentinel, b"KEEP").unwrap(); + symlink(&sentinel, f.path().join(".a.rs.licet.tmp")).unwrap(); + let out = f + .licet() + .args(["apply", "--allow-dirty", "--files", "a.rs"]) + .output() + .unwrap(); + assert!(out.status.success(), "{:?}", out); + assert_eq!(std::fs::read(&sentinel).unwrap(), b"KEEP"); + assert!( + !std::fs::symlink_metadata(f.path().join("a.rs")) + .unwrap() + .file_type() + .is_symlink() + ); +} + +#[test] +fn span_replace_preserves_surrounding_code() { + // Destructive apply swaps exactly the license value bytes: a code prefix + // and a same-line closer on the tag's line survive byte-for-byte. + let f = Fixture::new(); + f.config("[default]\nlicense=\"Apache-2.0\"\n") + .write("a.rs", "fn a(){} /* SPDX-License-Identifier: MIT */\n") + .commit("init"); + + let out = f.licet().arg("apply").output().unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + assert_eq!( + f.read("a.rs"), + "fn a(){} /* SPDX-License-Identifier: Apache-2.0 */\n" + ); + + let check = f.licet().arg("check").output().unwrap(); + assert_eq!( + check.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&check.stdout) + ); +} // REUSE-IgnoreEnd diff --git a/tests/snapshots/us1_snapshots__compliant_report_human.snap b/tests/snapshots/us1_snapshots__compliant_report_human.snap index 3c2a5e6..0b5cb63 100644 --- a/tests/snapshots/us1_snapshots__compliant_report_human.snap +++ b/tests/snapshots/us1_snapshots__compliant_report_human.snap @@ -3,5 +3,5 @@ source: tests/us1_snapshots.rs expression: stdout --- -Summary: 1 compliant, 0 wrong-license, 0 missing, 0 uncovered, 1 excluded, 0 unreadable +Summary: 1 compliant, 0 wrong-license, 0 missing, 0 uncovered, 2 excluded, 0 unreadable Result: PASS diff --git a/tests/snapshots/us1_snapshots__drift_report_human.snap b/tests/snapshots/us1_snapshots__drift_report_human.snap index 00b0f37..198dbb9 100644 --- a/tests/snapshots/us1_snapshots__drift_report_human.snap +++ b/tests/snapshots/us1_snapshots__drift_report_human.snap @@ -7,5 +7,5 @@ missing_header c_missing.py (declared `MIT`, no header found) wrong_license d_wrong.rs (declared `Apache-2.0` vs actual `MIT`) [ext=rs] missing_header license.toml (declared `MIT`, no header found) -Summary: 1 compliant, 2 wrong-license, 2 missing, 0 uncovered, 0 excluded, 0 unreadable +Summary: 1 compliant, 2 wrong-license, 2 missing, 0 uncovered, 3 excluded, 0 unreadable Result: FAIL diff --git a/tests/us1_check_drift.rs b/tests/us1_check_drift.rs index 5a02162..e102fe1 100644 --- a/tests/us1_check_drift.rs +++ b/tests/us1_check_drift.rs @@ -34,7 +34,9 @@ fn marque_repo() -> Fixture { ) .write("nolicense.txt.rs", "fn y(){}\n") .write("uncovered.unknownext", "data\n") - .write("vendor/x.rs", "vendored\n"); + .write("vendor/x.rs", "vendored\n") + // Default coverage is tracked files. + .commit("init"); f } @@ -66,10 +68,16 @@ fn check_reports_drift_classes_and_exits_1() { #[test] fn compliant_files_pass() { let f = Fixture::new(); - f.config(MARQUE).write( - "src/lib.rs", - "// SPDX-License-Identifier: LicenseRef-MarqueLicense-1.0\nfn x(){}\n", - ); + f.config(MARQUE) + .write( + "src/lib.rs", + "// SPDX-License-Identifier: LicenseRef-MarqueLicense-1.0\nfn x(){}\n", + ) + // Policy check requires the referenced custom text to exist. + .write( + "LICENSES/LicenseRef-MarqueLicense-1.0.txt", + "Custom marque license text.\n", + ); // Evaluate only the compliant rust file (FR-013 subset) — exits 0. let out = f .licet() @@ -108,9 +116,94 @@ fn json_output_has_summary_counts() { .output() .unwrap(); let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); - assert_eq!(v["version"], 1); + assert_eq!(v["version"], 2); + assert_eq!(v["snapshot"], "worktree"); assert!(v["summary"]["counts"]["wrong_license"].as_u64().unwrap() >= 1); assert_eq!(v["summary"]["pass"], false); + assert_eq!(v["summary"]["complete"], true); +} + +#[test] +fn check_requires_texts_for_selected_scope() { + // Referenced texts are required even when every drift is compliant. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n"); + let out = f + .licet() + .args(["check", "--files", "a.rs"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("missing_license_text"), "{stdout}"); + assert!(stdout.contains("MIT"), "{stdout}"); + // Supplying the text flips the same gate to green. + f.write( + "LICENSES/MIT.txt", + licet::spdx::bundled_text("MIT").unwrap(), + ); + let out = f + .licet() + .args(["check", "--files", "a.rs"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(0), "{stdout}"); +} + +#[test] +fn check_ignores_texts_of_unreachable_desired_rules() { + // A rule matching nothing contributes no desired references: its missing + // text cannot fail a selected-file policy check (only full lint sees it). + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[[rule]]\nglob=\"special/**\"\nlicense=\"Apache-2.0\"\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .texts(&["MIT"]); + let out = f + .licet() + .args(["check", "--files", "a.rs"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stdout) + ); +} + +#[test] +fn copyright_mismatch_is_its_own_drift_and_failure() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\ncopyright=\"add:2026 Acme\"\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .texts(&["MIT"]); + let out = f + .licet() + .args(["check", "--format", "json", "--files", "a.rs"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let entry = v["files"] + .as_array() + .unwrap() + .iter() + .find(|e| e["path"] == "a.rs") + .unwrap(); + assert_eq!(entry["drift"], "copyright_mismatch"); + assert_eq!(entry["declared"], "add:2026 Acme"); + // The requested notice alongside history satisfies the policy. + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2024 Old\n// SPDX-FileCopyrightText: 2026 Acme\nfn a(){}\n", + ); + let out = f + .licet() + .args(["check", "--files", "a.rs"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(0)); } #[test] @@ -121,7 +214,9 @@ fn semantic_expression_equivalence_is_compliant() { .write( "a.rs", "// SPDX-License-Identifier: Apache-2.0 OR MIT\nfn a(){}\n", - ); + ) + // Policy check requires both referenced texts. + .texts(&["MIT", "Apache-2.0"]); let out = f .licet() .args(["check", "--files", "a.rs"]) diff --git a/tests/us1_snapshots.rs b/tests/us1_snapshots.rs index 2963573..7d2e254 100644 --- a/tests/us1_snapshots.rs +++ b/tests/us1_snapshots.rs @@ -20,6 +20,8 @@ fn drift_report_human() { ) .write("c_missing.py", "z=3\n") .write("d_wrong.rs", "// SPDX-License-Identifier: MIT\nfn d(){}\n") + // Referenced texts exist so the snapshot stays about drift, not texts. + .texts(&["MIT", "Apache-2.0", "GPL-3.0-only"]) .commit("init"); let out = f.licet().args(["check"]).output().unwrap(); @@ -48,6 +50,7 @@ fn compliant_report_human() { let f = Fixture::new(); f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") .write("a.py", "# SPDX-License-Identifier: MIT\nx=1\n") + .texts(&["MIT"]) .commit("init"); let out = f.licet().args(["check"]).output().unwrap(); diff --git a/tests/us2_apply.rs b/tests/us2_apply.rs index 6346558..9042fb1 100644 --- a/tests/us2_apply.rs +++ b/tests/us2_apply.rs @@ -73,9 +73,9 @@ fn additive_keeps_both_and_warns_contradiction() { assert!(content.contains("Apache-2.0"), "old kept: {content}"); assert!(content.contains("MIT"), "new added: {content}"); let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); - let warnings = v["warnings"].as_array().cloned().unwrap_or_default(); + let diagnostics = v["diagnostics"].as_array().cloned().unwrap_or_default(); assert!( - warnings.iter().any(|w| w["kind"] == "contradiction"), + diagnostics.iter().any(|w| w["code"] == "contradiction"), "expected contradiction warning: {}", String::from_utf8_lossy(&out.stdout) ); @@ -121,4 +121,493 @@ fn target_header_replaces_chosen_block() { ); assert!(!content.contains("ISC"), "second block replaced: {content}"); } + +#[test] +fn apply_materializes_projected_texts_only() { + // Apply supplies texts for its projected state: the MIT intent it writes. + // The stale replaced Apache license and the unmatched rule's license are + // never fetched; nothing is ever deleted. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[[rule]]\nglob=\"special/**\"\nlicense=\"Apache-2.0\"\n") + .write("a.rs", "// SPDX-License-Identifier: Apache-2.0\nfn a(){}\n") + .commit("init"); + let out = f + .licet() + .args(["apply", "--files", "a.rs"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + assert!( + f.path().join("LICENSES/MIT.txt").exists(), + "projected intent text is supplied" + ); + assert!( + !f.path().join("LICENSES/Apache-2.0.txt").exists(), + "stale replaced and unmatched-rule licenses are not fetched" + ); +} + +#[test] +fn destructive_apply_strips_additional_licenses() { + // One matching candidate never hides an additional license: a file with + // MIT plus Apache under a lone MIT intent is wrong_license, and + // destructive apply reduces it to exactly the declared expression. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n") + .write( + "a.rs", + "// SPDX-License-Identifier: MIT OR Apache-2.0\nfn a(){}\n", + ) + // Referenced texts exist up front (the tracked snapshot cannot see + // files apply materializes but leaves untracked). + .texts(&["MIT", "Apache-2.0"]) + .commit("init"); + let out = f + .licet() + .args(["apply", "--files", "a.rs"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + let content = f.read("a.rs"); + assert!( + content.contains("// SPDX-License-Identifier: MIT\n"), + "reduced to exactly MIT: {content}" + ); + assert!(!content.contains("Apache-2.0"), "{content}"); + let out = f + .licet() + .args(["check", "--files", "a.rs"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stdout) + ); +} + +#[test] +fn program_text_tag_is_unfixable_and_untouched() { + // A tag in program text (no comment span) must not be rewritten: apply + // reports an actionable `unfixable` diagnostic, writes nothing, and fails + // the gate. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n") + .write( + "a.rs", + "FOO=SPDX-License-Identifier: Apache-2.0\nfn a(){}\n", + ) + // The fixture's own config file carries a header so the only failure + // is the unfixable one (exit 1, not partial). + .write( + "license.toml", + "# SPDX-License-Identifier: MIT\n[default]\nlicense=\"MIT\"\n", + ) + .commit("init"); + let before = f.read("a.rs"); + + let out = f + .licet() + .args(["apply", "--format", "json"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(1), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + assert_eq!(f.read("a.rs"), before, "program text must not be rewritten"); + + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let diagnostics = v["diagnostics"].as_array().cloned().unwrap_or_default(); + assert!( + diagnostics + .iter() + .any(|w| w["code"] == "unfixable" && w["path"] == "a.rs"), + "expected unfixable warning for a.rs: {}", + String::from_utf8_lossy(&out.stdout) + ); + let files = v["files"].as_array().cloned().unwrap_or_default(); + let entry = files + .iter() + .find(|e| e["path"] == "a.rs") + .expect("a.rs in report"); + assert_eq!(entry["change"]["applied"], false); +} + +#[test] +fn matching_license_missing_copyright_is_a_real_change() { + // Copyright intent applies even when the license already matches: a + // missing requested notice is written, not skipped as compliant. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\ncopyright=\"add:2026 Acme\"\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .commit("init"); + + let out = f.licet().arg("apply").output().unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + assert_eq!( + f.read("a.rs"), + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 Acme\nfn a(){}\n" + ); + + let check = f.licet().arg("check").output().unwrap(); + assert_eq!( + check.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&check.stdout) + ); +} + +/// Byte-hash every tracked file: dry-run must change nothing on disk. +fn tree_hash(f: &common::Fixture) -> std::collections::BTreeMap> { + let mut out = std::collections::BTreeMap::new(); + let root = f.path(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let mut entries: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .collect::>() + .unwrap(); + entries.sort_by_key(|e| e.path()); + for e in entries { + let p = e.path(); + if p.join(".git").exists() || p.ends_with(".git") { + continue; + } + if p.is_dir() { + stack.push(p); + } else { + out.insert( + p.strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"), + std::fs::read(&p).unwrap(), + ); + } + } + } + // The temp dir itself may hold other files; the LICENSES absence check + // below pins non-creation separately. + out +} + +#[test] +fn dry_run_changes_nothing_and_previews_planned_paths() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n") + .write("a.rs", "// SPDX-License-Identifier: Apache-2.0\nfn a(){}\n") + .commit("init"); + let before = tree_hash(&f); + + let out = f + .licet() + .args(["apply", "--dry-run", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(tree_hash(&f), before, "dry-run changed the tree"); + assert!( + !f.path().join("LICENSES").exists(), + "dry-run created LICENSES/" + ); + + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let writes = v["writes"].as_array().cloned().unwrap_or_default(); + assert!(!writes.is_empty(), "dry-run previews planned writes"); + assert!( + writes.iter().all(|w| w["status"] == "planned"), + "dry-run writes are all planned: {writes:?}" + ); + let src_write = writes + .iter() + .find(|w| w["path"] == "a.rs" && w["kind"] == "source") + .expect("planned source write for a.rs"); + assert!( + src_write["before_text"] + .as_str() + .unwrap() + .contains("Apache-2.0"), + "exact before text previewed" + ); + assert!( + src_write["after_text"] + .as_str() + .unwrap() + .contains("SPDX-License-Identifier: MIT"), + "exact after text previewed" + ); + // Fixable drift projects success. + assert_eq!(v["summary"]["projected_pass"], true); + assert_eq!(v["summary"]["pass"], true); +} + +#[test] +fn reapply_converges_to_empty_writes() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n") + .write("a.rs", "fn a(){}\n") + .commit("init"); + let out = f.licet().arg("apply").output().unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + + // Re-run converges: no drift left, so no writes at all. + let out = f + .licet() + .args(["apply", "--allow-dirty", "--format", "json"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!( + v.get("writes") + .is_none_or(|w| w.as_array().is_some_and(|a| a.is_empty())), + "re-apply emits an empty changed set: {v}" + ); +} + +#[test] +fn dry_run_preview_paths_match_real_applied_paths() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n") + .write("a.rs", "// SPDX-License-Identifier: Apache-2.0\nfn a(){}\n") + .write("b.rs", "fn b(){}\n") + .commit("init"); + + let dry = f + .licet() + .args(["apply", "--dry-run", "--format", "json"]) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&dry.stdout).unwrap(); + let planned: std::collections::BTreeSet = v["writes"] + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .map(|w| w["path"].as_str().unwrap().to_string()) + .collect(); + + let real = f + .licet() + .args(["apply", "--format", "json"]) + .output() + .unwrap(); + assert_eq!( + real.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&real.stdout) + ); + let v: serde_json::Value = serde_json::from_slice(&real.stdout).unwrap(); + let applied: std::collections::BTreeSet = v["writes"] + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .filter(|w| w["status"] == "applied") + .map(|w| w["path"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(planned, applied, "preview/apply path equality"); +} + +#[test] +fn custom_text_missing_blocks_apply_with_exit_1() { + // A referenced custom text nobody supplied is an explicit blocker: the + // header is still written per intent, but the gate cannot pass. + let f = Fixture::new(); + f.config("[default]\nlicense=\"LicenseRef-MarqueLicense-1.0\"\n") + .write("a.rs", "fn a(){}\n") + .commit("init"); + + let out = f + .licet() + .args(["apply", "--format", "json"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(1), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + assert!( + f.read("a.rs") + .contains("SPDX-License-Identifier: LicenseRef-MarqueLicense-1.0"), + "header still written per intent" + ); + assert!(!f.path().join("LICENSES").exists(), "no text scaffolded"); + + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let writes = v["writes"].as_array().cloned().unwrap_or_default(); + let blocked = writes + .iter() + .find(|w| w["status"] == "blocked" && w["kind"] == "license_text") + .expect("blocked license_text record"); + assert_eq!(blocked["path"], "LICENSES/LicenseRef-MarqueLicense-1.0.txt"); + assert_eq!(v["summary"]["pass"], false); +} + +#[test] +fn dry_run_never_fetches_missing_fetchable_text() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"CDDL-1.0\"\n") + .write("a.rs", "// SPDX-License-Identifier: CDDL-1.0\nfn a(){}\n") + .commit("init"); + let before = tree_hash(&f); + + let out = f + .licet() + .args(["apply", "--dry-run", "--format", "json"]) + .output() + .unwrap(); + // A required fetch that has not occurred fails the projection without + // any network use: zero filesystem changes, zero curl invocations (the + // sandbox has no network; any fetch attempt would error loudly). + assert_eq!( + out.status.code(), + Some(1), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + assert_eq!(tree_hash(&f), before, "dry-run changed the tree"); + assert!(!f.path().join("LICENSES").exists()); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["summary"]["projected_pass"], false); + let writes = v["writes"].as_array().cloned().unwrap_or_default(); + assert!( + writes.iter().any(|w| w["status"] == "blocked" + && w["message"] + .as_str() + .unwrap_or_default() + .contains("https://")), + "blocked fetch names its URL: {writes:?}" + ); +} + +#[test] +fn human_output_uses_distinct_write_words() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n") + .write("a.rs", "fn a(){}\n") + .commit("init"); + + let dry = f.licet().args(["apply", "--dry-run"]).output().unwrap(); + let text = String::from_utf8_lossy(&dry.stdout).into_owned(); + assert!(text.contains("[planned]"), "planned word: {text}"); + + let real = f.licet().arg("apply").output().unwrap(); + let text = String::from_utf8_lossy(&real.stdout).into_owned(); + assert!(text.contains("[applied]"), "applied word: {text}"); + + // A blocked inventory requirement says so explicitly. + let g = Fixture::new(); + g.config("[default]\nlicense=\"LicenseRef-MarqueLicense-1.0\"\n") + .write("a.rs", "fn a(){}\n") + .commit("init"); + let out = g.licet().arg("apply").output().unwrap(); + let text = String::from_utf8_lossy(&out.stdout).into_owned(); + assert!(text.contains("[blocked]"), "blocked word: {text}"); +} + +#[test] +fn additive_success_with_unfixable_remainder_is_exit_1_not_partial() { + // Writes that all succeed but leave declaration drift are violations + // (exit 1), not partial: partial means the tool itself failed partway. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n") + .write("a.rs", "fn a(){}\n") + .write( + "b.rs", + "FOO=SPDX-License-Identifier: Apache-2.0\nfn b(){}\n", + ) + .write( + "license.toml", + "# SPDX-License-Identifier: MIT\n[default]\nlicense=\"MIT\"\n", + ) + .commit("init"); + + let out = f + .licet() + .args(["apply", "--additive", "--format", "json"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(1), + "not partial: {}", + String::from_utf8_lossy(&out.stdout) + ); + assert!( + f.read("a.rs").contains("SPDX-License-Identifier: MIT"), + "fixable file fixed" + ); + let before = "FOO=SPDX-License-Identifier: Apache-2.0\nfn b(){}\n"; + assert_eq!(f.read("b.rs"), before, "unfixable file untouched"); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["summary"]["partial"], false); +} + +#[test] +fn php_header_inserts_after_open_tag() { + // The PHP open tag must stay the first line; the header follows it. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n") + .write("index.php", "\n

hi

\n", + ) + .commit("init"); + + let out = f + .licet() + .args(["apply", "--additive", "--files", "page.html"]) + .output() + .unwrap(); + // Additive writes that all succeed but leave declaration drift (the old + // record still counts) are exit 1, not partial: the tool did its job, + // the declarations still disagree. + assert_eq!( + out.status.code(), + Some(1), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + assert_eq!( + f.read("page.html"), + "\n\n

hi

\n" + ); +} + +/// Invalid configuration fails before any write: an unknown comment-style +/// alias is a usage error (exit 2) and `apply` changes nothing. +#[test] +fn unknown_comment_style_alias_fails_before_writes() { + let f = Fixture::new(); + f.config( + "[default]\nlicense=\"MIT\"\n[[comment_style]]\next=\"rs\"\nstyle=\"no-such-style\"\n", + ) + .write("a.rs", "fn a(){}\n") + .commit("init"); + + let out = f + .licet() + .args(["apply", "--allow-dirty", "--files", "a.rs"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("unknown comment style"), "{stderr}"); + assert_eq!(f.read("a.rs"), "fn a(){}\n", "no write on invalid config"); +} + +/// An empty `add:` copyright is a usage error, not an empty rendered line. +#[test] +fn empty_copyright_text_fails_before_writes() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\ncopyright=\"add:\"\n") + .write("a.rs", "fn a(){}\n") + .commit("init"); + + let out = f + .licet() + .args(["check", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2)); + assert_eq!(f.read("a.rs"), "fn a(){}\n", "no write on invalid config"); +} // REUSE-IgnoreEnd diff --git a/tests/us3_detect.rs b/tests/us3_detect.rs new file mode 100644 index 0000000..ac253c9 --- /dev/null +++ b/tests/us3_detect.rs @@ -0,0 +1,238 @@ +//! Detection semantics: complete-content SPDX scans, AST comparison, invalid-value +//! diagnosis, and cross-language header recognition (F12, FR-005, FR-008, FR-030). + +mod common; + +use common::Fixture; +use licet::detect::detect; +use licet::reuse::oob::OutOfBand; +use std::path::PathBuf; + +fn detect_text(name: &str, text: &str) -> licet::domain::ActualLicenseState { + detect( + &PathBuf::from(name), + text.as_bytes(), + None, + &OutOfBand::default(), + ) +} + +#[test] +fn compound_parenthesized_both_orders_agree() { + // Same logical set in two shapes: compliant under either declaration order. + for header in [ + "// SPDX-License-Identifier: (MIT OR Apache-2.0)\n", + "// SPDX-License-Identifier: Apache-2.0 OR (MIT)\n", + ] { + let f = Fixture::new(); + f.config("[default]\nlicense=\"Apache-2.0 OR MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("a.rs", header) + .texts(&["MIT", "Apache-2.0"]) + .commit("init"); + let out = f + .licet() + .args(["check", "--format", "json"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{header}: {}", + String::from_utf8_lossy(&out.stderr) + ); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let entry = report["files"] + .as_array() + .unwrap() + .iter() + .find(|e| e["path"] == "a.rs") + .unwrap(); + assert_eq!(entry["drift"], "compliant", "{header}"); + } +} + +#[test] +fn tab_separated_operators_pass_gate() { + // One tag line, tab-separated, lowercase operators: the same logical set as + // declared. (Identifier case stays canonical here: detection acceptance is + // strict — lowercase ids are diagnosed as invalid — while comparison + // tolerates id case; see the spdx unit tests.) + let f = Fixture::new(); + f.config("[default]\nlicense=\"Apache-2.0 OR MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write( + "a.rs", + "// SPDX-FileCopyrightText: 2026 Acme\n// SPDX-License-Identifier: MIT\tor\tApache-2.0\n", + ) + .texts(&["MIT", "Apache-2.0"]) + .commit("init"); + let out = f + .licet() + .args(["check", "--format", "json"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn compound_detection_keeps_full_expression() { + // Detection preserves the header's expression text; comparison is semantic. + let st = detect_text("a.rs", "// SPDX-License-Identifier: Apache-2.0 OR MIT\n"); + assert_eq!(st.detected_license.as_deref(), Some("Apache-2.0 OR MIT")); +} + +#[test] +fn additive_apply_flags_contradiction() { + // `apply --additive` that leaves two contradictory licenses warns explicitly. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("a.rs", "// SPDX-License-Identifier: Apache-2.0\n") + .commit("init"); + let out = f + .licet() + .args(["apply", "--allow-dirty", "--additive", "--format", "json"]) + .output() + .unwrap(); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!( + report["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|w| w["code"] == "contradiction"), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + assert!(f.read("a.rs").contains("Apache-2.0")); + assert!(f.read("a.rs").contains("MIT")); +} + +#[test] +fn header_beyond_old_head_cutoff_is_detected() { + // 9 KiB of leading code once hid the header past the 8 KiB scan cutoff. + let mut text = "// padding\n".repeat(900); + text.push_str("// SPDX-License-Identifier: MIT\n"); + assert!(text.len() > 8 * 1024); + let st = detect_text("a.rs", &text); + assert_eq!(st.detected_license.as_deref(), Some("MIT")); +} + +#[test] +fn invalid_utf8_late_in_file_is_unreadable() { + let mut bytes = b"// SPDX-License-Identifier: MIT\n".to_vec(); + bytes.extend_from_slice(&[0xff, 0xfe]); + let st = detect(&PathBuf::from("a.rs"), &bytes, None, &OutOfBand::default()); + assert!(!st.encoding_ok); +} + +#[test] +fn invalid_license_value_diagnosed_with_line_and_copyrights_kept() { + let text = "// SPDX-FileCopyrightText: 2026 Acme\n// SPDX-License-Identifier: Not A License\n"; + let st = detect_text("a.rs", text); + // No valid license: nothing detected … + assert_eq!(st.detected_license, None); + // … but the copyright survives and the value is diagnosed, not dropped. + assert!( + st.detected_copyrights + .iter() + .any(|c| c.contains("2026 Acme")) + ); + assert_eq!(st.invalid_license_values.len(), 1); + assert_eq!(st.invalid_license_values[0].line, 2); + assert_eq!(st.invalid_license_values[0].value, "Not A License"); +} + +#[test] +fn invalid_value_warning_reaches_gate_output() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write( + "a.rs", + "// SPDX-FileCopyrightText: 2026 Acme\n// SPDX-License-Identifier: Bogus-1.0\n", + ) + .commit("init"); + let out = f + .licet() + .args(["check", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let diagnostics = report["diagnostics"].as_array().unwrap(); + assert!( + diagnostics.iter().any(|w| w["code"] == "invalid_license" + && w["path"] == "a.rs" + && w["message"] + .as_str() + .unwrap_or_default() + .contains("Bogus-1.0")), + "{}", + String::from_utf8_lossy(&out.stdout) + ); +} + +#[test] +fn orphan_sidecar_content_has_no_effect() { + // A valid orphan sidecar is diagnosed AND its text leaks into no file state. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("ok.rs", "// SPDX-License-Identifier: MIT\n") + .write("ghost.rs.license", "SPDX-License-Identifier: Apache-2.0\n") + .commit("init"); + let out = f + .licet() + .args(["check", "--format", "json"]) + .output() + .unwrap(); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!( + report["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|w| w["code"] == "orphan_sidecar" && w["path"] == "ghost.rs.license"), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + assert!( + !report["files"] + .as_array() + .unwrap() + .iter() + .any(|e| e["actual"] == "Apache-2.0"), + "orphan text must not leak into any file state: {}", + String::from_utf8_lossy(&out.stdout) + ); +} + +#[test] +fn language_table_headers_detected() { + // tests/fixtures/table/sample.{py,sh,rb,js,jsx}: each native comment style + // carries the same header, and detection reads all of them. + for (name, content) in [ + ("sample.py", include_str!("fixtures/table/sample.py")), + ("sample.sh", include_str!("fixtures/table/sample.sh")), + ("sample.rb", include_str!("fixtures/table/sample.rb")), + ("sample.js", include_str!("fixtures/table/sample.js")), + ("sample.jsx", include_str!("fixtures/table/sample.jsx")), + ] { + let st = detect( + &PathBuf::from(name), + content.as_bytes(), + None, + &OutOfBand::default(), + ); + assert_eq!(st.detected_license.as_deref(), Some("MIT"), "{name}"); + assert!( + st.detected_copyrights + .iter() + .any(|c| c.contains("2026 Acme")), + "{name}" + ); + assert!(st.encoding_ok, "{name}"); + } +} diff --git a/tests/us4_enforce.rs b/tests/us4_enforce.rs index 8bb2955..8a4de56 100644 --- a/tests/us4_enforce.rs +++ b/tests/us4_enforce.rs @@ -29,8 +29,10 @@ fn staged_subset_blocks_on_drift_and_names_file() { #[test] fn compliant_staged_set_passes() { let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n").commit("baseline"); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .commit("baseline"); f.write("ok.rs", "// SPDX-License-Identifier: MIT\nfn o(){}\n") + .texts(&["MIT"]) .stage_all(); let out = f.licet().args(["check", "--staged"]).output().unwrap(); assert_eq!( @@ -41,6 +43,79 @@ fn compliant_staged_set_passes() { ); } +#[test] +fn staged_check_reads_index_bytes() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("f.rs", "// SPDX-License-Identifier: MIT\n") + .commit("base"); + f.write("f.rs", "// SPDX-License-Identifier: Apache-2.0\n") + .stage_all(); + f.write("f.rs", "// SPDX-License-Identifier: MIT\n"); + let out = f + .licet() + .args(["check", "--staged", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!( + report["files"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["path"] == "f.rs" && entry["actual"] == "Apache-2.0") + ); +} + +/// The index content tree before/after a read-only command: staged checks must +/// never mutate the index. +fn index_tree(f: &Fixture) -> String { + String::from_utf8_lossy(&f.git(&["write-tree"])) + .trim() + .to_string() +} + +#[test] +fn staged_check_reverse_content_case() { + // Index says MIT (compliant), worktree says Apache: the gate sees the index. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("f.rs", "// SPDX-License-Identifier: Apache-2.0\n") + .texts(&["MIT", "Apache-2.0"]) + .commit("base"); + f.write("f.rs", "// SPDX-License-Identifier: MIT\n") + .stage_all(); + f.write("f.rs", "// SPDX-License-Identifier: Apache-2.0\n"); + let before = index_tree(&f); + let out = f + .licet() + .args(["check", "--staged", "--format", "json"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!( + report["files"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["path"] == "f.rs" && entry["actual"] == "MIT"), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + assert_eq!( + index_tree(&f), + before, + "staged reads must not mutate the index" + ); +} + #[test] fn non_utf8_file_is_unreadable_and_fails_gate() { let f = Fixture::new(); @@ -56,6 +131,372 @@ fn non_utf8_file_is_unreadable_and_fails_gate() { assert!(String::from_utf8_lossy(&out.stdout).contains("unreadable")); } +#[test] +fn file_selection_normalizes_dot_segments() { + // `src/x.rs`, `./src/x.rs`, and `src/../src/x.rs` name one file. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("src/x.rs", "fn x(){}\n") + .commit("init"); + for spelling in ["src/x.rs", "./src/x.rs", "src/../src/x.rs"] { + let out = f + .licet() + .args(["check", "--files", spelling, "--format", "json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1), "{spelling}"); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let files = report["files"].as_array().unwrap(); + assert_eq!(files.len(), 1, "{spelling}"); + assert_eq!(files[0]["path"], "src/x.rs", "{spelling}"); + } +} + +#[test] +fn file_selection_from_subdirectory() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("a.rs", "fn a(){}\n") + .write("sub/b.rs", "fn b(){}\n") + .commit("init"); + // Parent-relative selection from inside sub/. + let out = f + .licet_in("sub") + .args(["check", "--files", "../a.rs", "--format", "json"]) + .output() + .unwrap(); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(report["files"].as_array().unwrap().len(), 1); + assert_eq!(report["files"][0]["path"], "a.rs"); + // Sibling-relative selection from inside sub/. + let out = f + .licet_in("sub") + .args(["check", "--files", "b.rs", "--format", "json"]) + .output() + .unwrap(); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(report["files"][0]["path"], "sub/b.rs"); +} + +#[test] +fn file_selection_rejects_outside_root() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n").commit("init"); + for spelling in [ + "../outside.rs", + "sub/../../outside.rs", + "/absolutely/outside.rs", + ] { + let out = f + .licet() + .args(["check", "--files", spelling]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2), "{spelling}"); + } +} + +#[cfg(unix)] +#[test] +fn file_selection_skips_symlinks_regardless_of_order() { + use std::os::unix::fs::symlink; + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("real.rs", "// SPDX-License-Identifier: MIT\n") + .texts(&["MIT"]) + .commit("init"); + symlink("real.rs", f.path().join("alias.rs")).unwrap(); + for files in [["alias.rs", "real.rs"], ["real.rs", "alias.rs"]] { + let out = f + .licet() + .args(["check", "--files", files[0], files[1], "--format", "json"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{files:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let entries = report["files"].as_array().unwrap(); + assert!( + entries + .iter() + .any(|e| e["path"] == "real.rs" && e["drift"] == "compliant") + ); + assert!( + entries + .iter() + .any(|e| e["path"] == "alias.rs" && e["drift"] == "excluded") + ); + } + // The symlink itself is never replaced by an ordinary file. + assert!( + std::fs::symlink_metadata(f.path().join("alias.rs")) + .unwrap() + .file_type() + .is_symlink() + ); +} + +// Non-UTF-8 filenames cannot be created on filesystems that enforce UTF-8 +// names (e.g. macOS APFS rejects them at creation); Linux CI covers this. +#[cfg(target_os = "linux")] +#[test] +fn non_utf8_filename_survives_selection() { + use std::os::unix::ffi::OsStringExt; + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .commit("init"); + // Latin-1 `caf\xe9.rs`: not valid UTF-8, but a real tracked file. + let name = std::ffi::OsString::from_vec(b"caf\xe9.rs".to_vec()); + std::fs::write(f.path().join(&name), b"fn x() {}\n").unwrap(); + f.git(&["add", "-A"]); + // Explicit selection by byte-exact name evaluates the file (missing header + // proves identity survived; a lossy path would be unreadable/absent). + let out = f + .licet() + .arg("check") + .arg("--files") + .arg(&name) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&out.stdout).contains("missing_header")); + // Full-tree coverage sees it too. + let out = f.licet().arg("check").output().unwrap(); + assert_eq!(out.status.code(), Some(1)); +} + +#[cfg(unix)] +#[test] +fn literal_backslash_is_not_a_separator() { + // A Unix filename containing a literal backslash must not be split. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .commit("init"); + std::fs::write(f.path().join("a\\b.rs"), b"fn x() {}\n").unwrap(); + f.git(&["add", "-A"]); + let out = f + .licet() + .args(["check", "--files", "a\\b.rs", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(report["files"].as_array().unwrap().len(), 1); + assert_eq!(report["files"][0]["drift"], "missing_header"); +} + +#[test] +fn reuse_ignore_matrix() { + use licet::spdx::bundled_text; + let mit = bundled_text("MIT").unwrap(); + // A root file literally named `LICENSES` is covered (not ignored); it gets + // its own fixture because `LICENSES/` is a directory everywhere else here. + let g = Fixture::new(); + g.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("LICENSES", "I am an ordinary covered file\n") + .commit("init"); + let out = g + .licet() + .args(["check", "--format", "json"]) + .output() + .unwrap(); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!( + report["files"] + .as_array() + .unwrap() + .iter() + .any(|e| e["path"] == "LICENSES" && e["drift"] == "missing_header"), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("LICENSES/MIT.txt", mit) + .write("COPYING", "x\n") + .write("LICENSE-MIT", "x\n") + .write("LICENCE.md", "x\n") + .write("sub/COPYING.GPL", "x\n") + .write("sub/LICENSE-MIT", "x\n") + .write("sub/LICENCE.md", "x\n") + .write("sub/LICENSES/a.rs", "fn a(){}\n") + .write("empty.rs", "") + .write("data.empty", "fn d(){}\n") + .write("sbom.spdx.json", "{}\n") + .write("subprojects/foo/a.rs", "fn a(){}\n") + .write("ok.rs", "// SPDX-License-Identifier: MIT\n") + .write( + "REUSE.toml", + "version = 1\n[[annotations]]\npath = \"ok.rs\"\nSPDX-License-Identifier = \"MIT\"\n", + ) + // NOTE: no `.reuse/dep5` here — REUSE.toml and DEP5 are mutually + // exclusive and their coexistence fails the scan (see + // `reuse_and_dep5_coexistence_fails_before_writes` in us5_reuse.rs). + .commit("init"); + let out = f + .licet() + .args(["check", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let files = report["files"].as_array().unwrap(); + let drift_of = |p: &str| { + files + .iter() + .find(|e| e["path"] == p) + .map(|e| e["drift"].as_str().unwrap().to_string()) + }; + // Covered: headerless sources fail; the compliant file passes. + for p in ["sub/LICENSES/a.rs", "data.empty"] { + assert_eq!(drift_of(p).as_deref(), Some("missing_header"), "{p}"); + } + assert_eq!(drift_of("ok.rs").as_deref(), Some("compliant")); + // Ignored by REUSE, at root and at depth. + for p in [ + "LICENSES/MIT.txt", + "COPYING", + "LICENSE-MIT", + "LICENCE.md", + "sub/COPYING.GPL", + "sub/LICENSE-MIT", + "sub/LICENCE.md", + "empty.rs", + "sbom.spdx.json", + "subprojects/foo/a.rs", + "REUSE.toml", + ] { + assert_eq!(drift_of(p).as_deref(), Some("excluded"), "{p}"); + } +} + +#[test] +fn lint_ignores_declaration_exclusions_but_sees_untracked() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\", \"hide.rs\"]\n") + .write("ok.rs", "// SPDX-License-Identifier: MIT\n") + .write("hide.rs", "fn h(){}\n") + .texts(&["MIT"]) + .commit("init"); + // Policy check honors the exclusion: clean gate. + let out = f.licet().arg("check").output().unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + // REUSE validation does not: the excluded-but-unlicensed file still fails. + let out = f.licet().arg("lint").output().unwrap(); + assert_eq!(out.status.code(), Some(1)); + // Untracked files are invisible to the policy gate but visible to lint. + f.write("new.rs", "fn n(){}\n"); + let out = f.licet().arg("check").output().unwrap(); + assert_eq!(out.status.code(), Some(0)); + let out = f.licet().arg("lint").output().unwrap(); + assert_eq!(out.status.code(), Some(1)); +} + +#[test] +fn tracked_gitignored_file_stays_covered() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("a.rs", "fn a(){}\n") + .commit("init"); + // A later ignore rule cannot drop a tracked file from policy coverage. + f.write(".gitignore", "a.rs\n"); + let out = f + .licet() + .args(["check", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!( + report["files"] + .as_array() + .unwrap() + .iter() + .any(|e| e["path"] == "a.rs" && e["drift"] == "missing_header"), + "{}", + String::from_utf8_lossy(&out.stdout) + ); +} + +#[test] +fn linked_worktree_dot_git_file_not_scanned() { + // In a linked worktree `.git` is a control FILE and enumeration comes from + // `ls-files`, so internals can never leak into coverage. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("ok.rs", "// SPDX-License-Identifier: MIT\n") + .texts(&["MIT"]) + .commit("init"); + let wt = tempfile::tempdir().unwrap(); + let wt_path = wt.path().join("wt"); + f.git(&["worktree", "add", "--detach", wt_path.to_str().unwrap()]); + assert!( + wt_path.join(".git").is_file(), + "linked worktree uses a .git file" + ); + let out = std::process::Command::new(common::bin()) + .current_dir(&wt_path) + .args(["check", "--format", "json"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!( + !report["files"] + .as_array() + .unwrap() + .iter() + .any(|e| e["path"].as_str().unwrap_or_default().starts_with(".git")), + "{}", + String::from_utf8_lossy(&out.stdout) + ); +} + +#[test] +fn git_internals_never_evaluated() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("ok.rs", "// SPDX-License-Identifier: MIT\n") + .texts(&["MIT"]) + .commit("init"); + let out = f + .licet() + .args(["check", "--format", "json"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!( + !report["files"] + .as_array() + .unwrap() + .iter() + .any(|e| e["path"].as_str().unwrap_or_default().starts_with(".git/")), + "{}", + String::from_utf8_lossy(&out.stdout) + ); +} + #[test] fn selection_flags_are_mutually_exclusive() { let f = Fixture::new(); @@ -72,11 +513,291 @@ fn selection_flags_are_mutually_exclusive() { ); } +#[test] +fn staged_check_on_unborn_head_evaluates_index() { + // No commit exists: the staged set is the full index. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("f.rs", "// SPDX-License-Identifier: MIT\n") + .texts(&["MIT"]) + .stage_all(); + let out = f + .licet() + .args(["check", "--staged", "--format", "json"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + // A file removed from the index on unborn HEAD is simply not evaluated. + f.git(&["rm", "--cached", "-q", "f.rs"]); + let out = f.licet().args(["check", "--staged"]).output().unwrap(); + assert_eq!(out.status.code(), Some(0)); +} + +#[test] +fn staged_rename_evaluates_new_path() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\n") + .texts(&["MIT"]) + .commit("base"); + f.git(&["mv", "a.rs", "b.rs"]); + let out = f + .licet() + .args(["check", "--staged", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(0)); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let files = report["files"].as_array().unwrap(); + assert!( + files + .iter() + .any(|e| e["path"] == "b.rs" && e["drift"] == "compliant") + ); + assert!(!files.iter().any(|e| e["path"] == "a.rs")); +} + +#[test] +fn unresolved_merge_index_is_usage_error() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("m.rs", "// base\n") + .commit("base"); + f.git(&["checkout", "-qb", "side"]); + f.write("m.rs", "// side\n").commit("side"); + f.git(&["checkout", "-q", "-"]); + f.write("m.rs", "// main\n").commit("main"); + // Conflict the merge without asserting success. + let merge = std::process::Command::new("git") + .current_dir(f.path()) + .args(["merge", "--no-commit", "side"]) + .output() + .unwrap(); + assert!( + !merge.status.success(), + "fixture must produce a real conflict" + ); + let out = f.licet().args(["check", "--staged"]).output().unwrap(); + assert_eq!(out.status.code(), Some(2)); + assert!( + String::from_utf8_lossy(&out.stderr).contains("unresolved merge"), + "{}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn staged_sidecar_supersedes_worktree_sidecar() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .texts(&["MIT", "Apache-2.0"]) + .commit("base"); + // Stage an Apache sidecar, then rewrite the working copy to MIT: the gate + // sees the staged sidecar. + f.write("a.rs.license", "SPDX-License-Identifier: Apache-2.0\n") + .stage_all(); + f.write("a.rs.license", "SPDX-License-Identifier: MIT\n"); + let before = index_tree(&f); + let out = f + .licet() + .args(["check", "--staged", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!( + report["files"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["path"] == "a.rs" && entry["actual"] == "Apache-2.0"), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + assert_eq!( + index_tree(&f), + before, + "staged reads must not mutate the index" + ); +} + +#[test] +fn staged_metadata_change_expands_to_full_set() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\n") + // Apache text up front: the staged override is about to reference it. + .texts(&["MIT", "Apache-2.0"]) + .commit("base"); + // Stage only metadata: an overriding Apache annotation for a.rs. The + // unchanged source is still affected, so the subset must expand. + f.write( + "REUSE.toml", + "version = 1\n[[annotations]]\npath = \"a.rs\"\nprecedence = \"override\"\n\ + SPDX-License-Identifier = \"Apache-2.0\"\n", + ) + .stage_all(); + let out = f + .licet() + .args(["check", "--staged", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let files = report["files"].as_array().unwrap(); + assert!( + files + .iter() + .any(|e| e["path"] == "a.rs" && e["drift"] == "wrong_license"), + "expansion must evaluate the unchanged source: {}", + String::from_utf8_lossy(&out.stdout) + ); + assert!( + report["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|w| w["code"] == "selection_expanded"), + "expansion must be reported: {}", + String::from_utf8_lossy(&out.stdout) + ); +} + +#[test] +fn staged_license_text_deletion_expands_selection() { + use licet::spdx::bundled_text; + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\n") + .write("LICENSES/MIT.txt", bundled_text("MIT").unwrap()) + .commit("base"); + // Stage only the text deletion: nothing evaluable remains in the subset, + // but the deletion can affect other files, so the check must expand. + f.git(&["rm", "-q", "LICENSES/MIT.txt"]); + let out = f + .licet() + .args(["check", "--staged", "--format", "json"]) + .output() + .unwrap(); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!( + report["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|w| w["code"] == "selection_expanded"), + "text deletion must expand the selection: {}", + String::from_utf8_lossy(&out.stdout) + ); + assert!( + report["files"] + .as_array() + .unwrap() + .iter() + .any(|e| e["path"] == "a.rs"), + "expansion must evaluate the referencing source: {}", + String::from_utf8_lossy(&out.stdout) + ); + // No exit-code assertion: `check` gains license-text inventory in task 5, + // which turns this missing text into a failure. +} + +#[test] +fn staged_custom_config_must_be_in_index() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n") + .write("f.rs", "// SPDX-License-Identifier: MIT\n") + .commit("base"); + f.write("custom.toml", "[default]\nlicense=\"Apache-2.0\"\n"); + // Untracked custom config: not part of the staged snapshot. + let out = f + .licet() + .args(["check", "--staged", "--config", "custom.toml"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2)); + // Once staged, the check reads the staged config (Apache default makes the + // MIT file drift — proving the index bytes were used, not license.toml). + f.git(&["add", "custom.toml"]); + let out = f + .licet() + .args([ + "check", + "--staged", + "--config", + "custom.toml", + "--format", + "json", + ]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!( + report["files"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["path"] == "f.rs" + && entry["actual"] == "MIT" + && entry["drift"] == "wrong_license"), + "{}", + String::from_utf8_lossy(&out.stdout) + ); +} + +#[test] +fn apply_staged_edits_worktree_leaves_index() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("f.rs", "// SPDX-License-Identifier: MIT\n") + .commit("base"); + f.write("f.rs", "// SPDX-License-Identifier: Apache-2.0\n") + .stage_all(); + let staged_before = String::from_utf8_lossy(&f.git(&["show", ":f.rs"])).into_owned(); + assert!(staged_before.contains("Apache-2.0")); + let out = f + .licet() + .args(["apply", "--staged", "--allow-dirty"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + // Worktree fixed, index untouched. + assert!(f.read("f.rs").contains("SPDX-License-Identifier: MIT")); + assert!( + String::from_utf8_lossy(&f.git(&["show", ":f.rs"])).contains("Apache-2.0"), + "apply must never stage its edits" + ); + // A staged-but-superseded worktree needs no write and still passes. + f.write("f.rs", "// SPDX-License-Identifier: MIT\n"); + let out = f + .licet() + .args(["apply", "--staged", "--allow-dirty"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(0)); + assert!(String::from_utf8_lossy(&f.git(&["show", ":f.rs"])).contains("Apache-2.0")); +} + #[test] fn explain_names_winning_rule() { let f = Fixture::new(); f.config("[[rule]]\next=\"rs\"\nlicense=\"MIT\"\n[[rule]]\nglob=\"examples/**/*.rs\"\nlicense=\"Apache-2.0\"\n") - .write("examples/d.rs", "fn d(){}\n"); + .write("examples/d.rs", "fn d(){}\n") + // Default coverage is tracked files; commit so the path is evaluated + // (task 9 makes --explain resolve arbitrary paths directly). + .commit("init"); let out = f .licet() .args(["check", "--explain", "examples/d.rs"]) @@ -85,4 +806,253 @@ fn explain_names_winning_rule() { let stdout = String::from_utf8_lossy(&out.stdout); assert!(stdout.contains("glob=examples/**/*.rs"), "{stdout}"); } + +#[test] +fn explain_reports_winner_losers_and_drift() { + let f = Fixture::new(); + f.config("[[rule]]\next=\"rs\"\nlicense=\"MIT\"\n[[rule]]\nglob=\"examples/**/*.rs\"\nlicense=\"Apache-2.0\"\n") + .write("examples/d.rs", "// SPDX-License-Identifier: MIT\nfn d(){}\n") + .commit("init"); + let out = f + .licet() + .args(["check", "--explain", "examples/d.rs"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(0)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("winning rule #2"), "{stdout}"); + assert!(stdout.contains("losing rule #1"), "{stdout}"); + assert!(stdout.contains("specificity"), "{stdout}"); + assert!(stdout.contains("wrong_license"), "{stdout}"); + assert!(stdout.contains("exclusions: none"), "{stdout}"); +} + +#[test] +fn explain_outside_selected_set_is_usage_error() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .write("b.rs", "// SPDX-License-Identifier: MIT\nfn b(){}\n") + .commit("init"); + let out = f + .licet() + .args(["check", "--files", "a.rs", "--explain", "b.rs"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(2), + "outside the selected set → exit 2" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("outside the selected file set"), "{stderr}"); +} + +#[test] +fn explain_nonexistent_path_is_usage_error() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n").commit("init"); + let out = f + .licet() + .args(["check", "--explain", "nope.rs"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(2), + "nonexistent → exit 2, not success" + ); +} + +#[test] +fn default_config_resolves_from_root_in_subdirectory() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .write("sub/b.rs", "// SPDX-License-Identifier: MIT\nfn b(){}\n") + .texts(&["MIT"]) + .commit("init"); + // No --config: the omitted default comes from the discovered root. + let out = f.licet_in("sub").arg("check").output().unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "root config applies from subdir: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn explicit_missing_config_is_usage_error() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .write("sub/b.rs", "// SPDX-License-Identifier: MIT\nfn b(){}\n") + .commit("init"); + let out = f + .licet() + .args(["check", "--config", "nope.toml"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2)); + // An explicit relative path stays cwd-relative: from a subdir without + // its own license.toml this names a missing file, unlike the omitted + // default which resolves from the root. + let out = f + .licet_in("sub") + .args(["check", "--config", "license.toml"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2)); +} + +#[test] +fn broken_stdout_pipe_exits_quietly() { + use std::io::Read; + use std::process::Stdio; + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .texts(&["MIT"]); + // Enough files that the JSON report exceeds the pipe buffer, so the + // writer deterministically hits EPIPE once the reader goes away. + for i in 0..2000 { + std::fs::write( + f.path().join(format!("f{i:04}.rs")), + "// SPDX-License-Identifier: MIT\nfn f(){}\n", + ) + .unwrap(); + } + f.commit("init"); + + let mut child = f + .licet() + .args(["check", "--format", "json"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut head = [0u8; 16]; + child + .stdout + .as_mut() + .unwrap() + .read_exact(&mut head) + .unwrap(); + drop(child.stdout.take()); + let out = child.wait_with_output().unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "closed pipe terminates quietly, got {:?}", + out.status.code() + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!stderr.contains("panicked"), "no panic on EPIPE: {stderr}"); +} + +#[test] +fn explain_json_is_a_single_parseable_document() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .commit("init"); + let out = f + .licet() + .args(["check", "--explain", "a.rs", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(0)); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(report["version"], 2); + assert_eq!(report["files"].as_array().unwrap().len(), 1); + assert_eq!(report["files"][0]["path"], "a.rs"); + assert_eq!(report["files"][0]["drift"], "compliant"); +} + +#[test] +fn equal_specificity_rule_conflict_is_nonzero_with_path() { + // Two identical selectors with differing full intent surface a conflict, + // never a silent pick: duplicate selectors fail config loading with the + // selector and both intents named, before anything is evaluated. + let f = Fixture::new(); + f.config( + "[[rule]]\nfile=\"a.rs\"\nlicense=\"MIT\"\n[[rule]]\nfile=\"a.rs\"\nlicense=\"Apache-2.0\"\n", + ) + .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .commit("init"); + + let out = f + .licet() + .args(["check", "--format", "json"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(2), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + assert!(stderr.contains("file=a.rs"), "{stderr}"); + assert!( + stderr.contains("MIT") && stderr.contains("Apache-2.0"), + "{stderr}" + ); + + // Apply cannot resolve it either: nonzero, file untouched. + let before = f.read("a.rs"); + let out = f.licet().arg("apply").output().unwrap(); + assert_eq!( + out.status.code(), + Some(2), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + assert_eq!(f.read("a.rs"), before); +} + +#[test] +fn resolution_time_equal_specificity_conflict_names_rules() { + // Distinct selectors of equal specificity with differing intent conflict + // at resolution: the diagnostic carries the path and both tied rules. + let f = Fixture::new(); + f.config("[[rule]]\nglob=\"*.rs\"\nlicense=\"MIT\"\n[[rule]]\nglob=\"a.*\"\nlicense=\"Apache-2.0\"\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .commit("init"); + + let out = f + .licet() + .args(["check", "--format", "json"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(1), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let diagnostics = v["diagnostics"].as_array().cloned().unwrap_or_default(); + let conflict = diagnostics + .iter() + .find(|d| d["code"] == "rule_conflict") + .expect("rule_conflict diagnostic"); + assert_eq!(conflict["path"], "a.rs"); + let message = conflict["message"].as_str().unwrap_or_default(); + assert!( + message.contains("glob=*.rs") && message.contains("glob=a.*"), + "{message}" + ); + + // Apply leaves the conflicted file alone and stays nonzero. + let before = f.read("a.rs"); + let out = f.licet().arg("apply").output().unwrap(); + assert_eq!( + out.status.code(), + Some(1), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + assert_eq!(f.read("a.rs"), before); +} // REUSE-IgnoreEnd diff --git a/tests/us5_add_license.rs b/tests/us5_add_license.rs index 2737cb6..7d5ef2c 100644 --- a/tests/us5_add_license.rs +++ b/tests/us5_add_license.rs @@ -57,7 +57,7 @@ fn re_run_is_idempotent_and_succeeds() { } #[test] -fn unknown_id_unavailable_offline_exits_violations() { +fn unknown_id_is_usage_error_before_any_write() { let f = Fixture::new(); f.write("a.rs", "fn a(){}\n").commit("init"); let out = f @@ -65,16 +65,37 @@ fn unknown_id_unavailable_offline_exits_violations() { .args(["add", "Definitely-Not-A-License-9.9"]) .output() .unwrap(); + assert_eq!(out.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("Definitely-Not-A-License-9.9"), + "names the invalid id: {stderr}" + ); + assert!( + !f.path().join("LICENSES").exists(), + "invalid id must not create any directory" + ); +} + +#[test] +fn unbundled_standard_id_without_network_exits_violations() { + let f = Fixture::new(); + f.write("a.rs", "fn a(){}\n").commit("init"); + let out = f.licet().args(["add", "Apache-1.0"]).output().unwrap(); assert_eq!(out.status.code(), Some(1)); let stdout = String::from_utf8_lossy(&out.stdout); assert!( - stdout.contains("Definitely-Not-A-License-9.9"), + stdout.contains("Apache-1.0"), "names the unavailable id: {stdout}" ); + assert!( + !f.path().join("LICENSES/Apache-1.0.txt").exists(), + "offline run must not create the text" + ); } #[test] -fn license_ref_is_scaffolded_as_placeholder() { +fn license_ref_requires_manual_text_without_scaffolding() { let f = Fixture::new(); f.write("a.rs", "fn a(){}\n").commit("init"); let out = f @@ -82,9 +103,270 @@ fn license_ref_is_scaffolded_as_placeholder() { .args(["add", "LicenseRef-Acme-1.0"]) .output() .unwrap(); - assert_eq!(out.status.code(), Some(0)); - let text = f.read("LICENSES/LicenseRef-Acme-1.0.txt"); - assert!(text.contains("TODO"), "placeholder scaffold: {text}"); + // Custom texts must be supplied by the maintainer: reported missing (exit 1), + // never downloaded, and never scaffolded with placeholder prose. + assert_eq!(out.status.code(), Some(1)); + assert!( + !f.path().join("LICENSES/LicenseRef-Acme-1.0.txt").exists(), + "no placeholder scaffold may be invented" + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("LicenseRef-Acme-1.0"), + "names the required text: {stdout}" + ); +} + +#[test] +fn path_escape_id_is_usage_error_and_keeps_victim() { + let f = Fixture::new(); + f.write("a.rs", "fn a(){}\n") + .write("victim.txt", "ORIGINAL") + .commit("init"); + let out = f + .licet() + .args(["add-license", "../victim", "--allow-network"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2)); + assert_eq!(f.read("victim.txt"), "ORIGINAL"); +} + +// Fake-curl tests: a `curl` shim on PATH records invocation via a marker file. + +#[cfg(unix)] +mod fake_curl { + use super::*; + use std::os::unix::fs::PermissionsExt; + + /// Create a dir containing an executable `curl` shim plus its marker path. + /// The shim touches `$FAKE_MARKER` on every invocation so tests can assert + /// curl was (not) called. Payload behavior is embedded per test. + fn shim_dir(script: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let curl = dir.path().join("curl"); + std::fs::write(&curl, script).unwrap(); + std::fs::set_permissions(&curl, std::fs::Permissions::from_mode(0o755)).unwrap(); + let marker = dir.path().join("invoked"); + (dir, marker) + } + + fn path_with(dir: &tempfile::TempDir) -> std::ffi::OsString { + let mut paths = vec![dir.path().to_path_buf()]; + paths.extend(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )); + std::env::join_paths(paths).unwrap() + } + + const RECORD_AND_FAIL: &str = "#!/bin/sh\ntouch \"$FAKE_MARKER\"\nexit 1\n"; + + const RECORD_AND_WRITE_PAYLOAD: &str = "#!/bin/sh\ntouch \"$FAKE_MARKER\"\nout=\"\"\nprev=\"\"\nfor a in \"$@\"; do\n if [ \"$prev\" = \"--output\" ]; then out=\"$a\"; fi\n prev=\"$a\"\ndone\nprintf '%s' \"$FAKE_PAYLOAD\" > \"$out\"\nexit 0\n"; + + #[test] + fn escape_id_exits_before_invoking_curl() { + let f = Fixture::new(); + f.write("a.rs", "fn a(){}\n") + .write("victim.txt", "ORIGINAL") + .commit("init"); + let (shim, marker) = shim_dir(RECORD_AND_FAIL); + let out = f + .licet() + .env("PATH", path_with(&shim)) + .env("FAKE_MARKER", &marker) + .args(["add-license", "../victim", "--allow-network"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2)); + assert_eq!(f.read("victim.txt"), "ORIGINAL"); + assert!(!marker.exists(), "curl must not be invoked for invalid ids"); + } + + #[test] + fn bundled_id_never_invokes_curl() { + let f = Fixture::new(); + f.write("a.rs", "fn a(){}\n").commit("init"); + let (shim, marker) = shim_dir(RECORD_AND_FAIL); + let out = f + .licet() + .env("PATH", path_with(&shim)) + .env("FAKE_MARKER", &marker) + .args(["add-license", "MIT", "--allow-network"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(!marker.exists(), "bundled text must not invoke curl"); + assert!( + f.read("LICENSES/MIT.txt") + .contains("Permission is hereby granted") + ); + } + + #[test] + fn fetch_success_installs_text() { + let f = Fixture::new(); + f.write("a.rs", "fn a(){}\n").commit("init"); + let (shim, marker) = shim_dir(RECORD_AND_WRITE_PAYLOAD); + let out = f + .licet() + .env("PATH", path_with(&shim)) + .env("FAKE_MARKER", &marker) + .env("FAKE_PAYLOAD", "FAKE APACHE-1.0 TEXT") + .args(["add-license", "Apache-1.0", "--allow-network"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(marker.exists(), "curl must be invoked for unbundled ids"); + assert_eq!(f.read("LICENSES/Apache-1.0.txt"), "FAKE APACHE-1.0 TEXT"); + // Exactly one file is written: the requested id at its destination. + let entries: Vec<_> = std::fs::read_dir(f.path().join("LICENSES")) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); + assert_eq!(entries, vec![std::ffi::OsString::from("Apache-1.0.txt")]); + } + + #[test] + fn fetch_failure_preserves_destination() { + let f = Fixture::new(); + f.write("a.rs", "fn a(){}\n") + .write("LICENSES/Apache-1.0.txt", "KEEP") + .commit("init"); + // Exit 22 (HTTP error with --fail) must not touch the destination. The + // file counts as present, so the run still succeeds. + let (shim, marker) = shim_dir("#!/bin/sh\ntouch \"$FAKE_MARKER\"\nexit 22\n"); + let out = f + .licet() + .env("PATH", path_with(&shim)) + .env("FAKE_MARKER", &marker) + .args(["add-license", "Apache-1.0", "--allow-network"]) + .output() + .unwrap(); + assert_eq!(f.read("LICENSES/Apache-1.0.txt"), "KEEP"); + assert!(!marker.exists(), "present text must not trigger a download"); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + + // Without the pre-existing text, the same failure is exit 1 with no file. + let g = Fixture::new(); + g.write("a.rs", "fn a(){}\n").commit("init"); + let out = g + .licet() + .env("PATH", path_with(&shim)) + .env("FAKE_MARKER", &marker) + .args([ + "add-license", + "Apache-1.0", + "--allow-network", + "--format", + "json", + ]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + assert!(!g.path().join("LICENSES/Apache-1.0.txt").exists()); + // JSON mode stays parseable even on download failure. + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["command"], "add-license"); + assert_eq!(v["summary"]["pass"], false); + } + + #[test] + fn empty_and_oversized_downloads_are_rejected() { + for (name, script) in [ + ( + "empty", + "#!/bin/sh\ntouch \"$FAKE_MARKER\"\nout=\"\"\nprev=\"\"\nfor a in \"$@\"; do\n if [ \"$prev\" = \"--output\" ]; then out=\"$a\"; fi\n prev=\"$a\"\ndone\n: > \"$out\"\nexit 0\n", + ), + ( + "oversized", + "#!/bin/sh\ntouch \"$FAKE_MARKER\"\nout=\"\"\nprev=\"\"\nfor a in \"$@\"; do\n if [ \"$prev\" = \"--output\" ]; then out=\"$a\"; fi\n prev=\"$a\"\ndone\nhead -c 4194305 /dev/zero > \"$out\"\nexit 0\n", + ), + ] { + let f = Fixture::new(); + f.write("a.rs", "fn a(){}\n").commit("init"); + let (shim, marker) = shim_dir(script); + let out = f + .licet() + .env("PATH", path_with(&shim)) + .env("FAKE_MARKER", &marker) + .args(["add-license", "Apache-1.0", "--allow-network"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1), "{name}"); + assert!(marker.exists(), "{name}: curl must have been attempted"); + assert!( + !f.path().join("LICENSES/Apache-1.0.txt").exists(), + "{name}: no text may be installed" + ); + } + } + + #[test] + fn curl_launch_failure_preserves_existing() { + // A present text never triggers a download, even with a broken curl. + let shim = tempfile::tempdir().unwrap(); + std::fs::write(shim.path().join("curl"), "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions( + shim.path().join("curl"), + std::fs::Permissions::from_mode(0o644), + ) + .unwrap(); + let f = Fixture::new(); + f.write("a.rs", "fn a(){}\n") + .write("LICENSES/Apache-1.0.txt", "KEEP") + .commit("init"); + let out = f + .licet() + .env("PATH", path_with(&shim)) + .args(["add-license", "Apache-1.0", "--allow-network"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(0)); + assert_eq!(f.read("LICENSES/Apache-1.0.txt"), "KEEP"); + + // An isolated PATH with a non-executable `curl` (spawn fails) and a + // symlinked `git` (repo discovery keeps working): the missing text is + // exit 1 and nothing is installed. + let isolated = tempfile::tempdir().unwrap(); + std::fs::write(isolated.path().join("curl"), "not executable\n").unwrap(); + std::fs::set_permissions( + isolated.path().join("curl"), + std::fs::Permissions::from_mode(0o644), + ) + .unwrap(); + let git_src = std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) + .map(|d| d.join("git")) + .find(|p| p.is_file()) + .expect("a git binary on PATH for the fixture"); + std::os::unix::fs::symlink(&git_src, isolated.path().join("git")).unwrap(); + let g = Fixture::new(); + g.write("a.rs", "fn a(){}\n").commit("init"); + let out = g + .licet() + .env("PATH", isolated.path()) + .args(["add-license", "Apache-1.0", "--allow-network"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + assert!(!g.path().join("LICENSES/Apache-1.0.txt").exists()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("download failed"), "{stdout}"); + } } #[test] @@ -150,4 +432,16 @@ fn json_output_is_well_formed() { assert_eq!(v["summary"]["pass"], true); assert!(v["license_texts"]["spdx_list_version"].is_string()); } + +#[test] +fn all_flag_propagates_config_errors() { + // `add-license --all` scans the union of desired and actual references, + // but a malformed policy config is usage error 2 — never swallowed. + let f = Fixture::new(); + f.config("[default\nbroken\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .commit("init"); + let out = f.licet().args(["add-license", "--all"]).output().unwrap(); + assert_eq!(out.status.code(), Some(2)); +} // REUSE-IgnoreEnd diff --git a/tests/us5_reuse.rs b/tests/us5_reuse.rs index 578c54c..f9b3364 100644 --- a/tests/us5_reuse.rs +++ b/tests/us5_reuse.rs @@ -61,6 +61,36 @@ fn version_reports_embedded_spdx_list_version() { ); } +/// The `--version` line is an exact contract: `licet (SPDX license +/// list )`, with the manifest version and the embedded list snapshot. +#[test] +fn version_line_is_exact() { + let f = Fixture::new(); + let out = f.licet().arg("--version").output().unwrap(); + assert_eq!(out.status.code(), Some(0)); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert_eq!( + stdout, + format!( + "licet {} (SPDX license list {})\n", + env!("CARGO_PKG_VERSION"), + licet::spdx::spdx_list_version() + ), + "exact --version contract" + ); + // The `add` alias spells the same command as `add-license`. + let out = f + .licet() + .args(["add", "LicenseRef-Acme-1.0"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&out.stdout).contains("LicenseRef-Acme-1.0"), + "alias `add` behaves as `add-license`" + ); +} + #[test] fn reuse_conformance_after_apply() { // SC-007: a reconciled repo passes the real `reuse lint`, when copyright is supplied. @@ -101,4 +131,992 @@ fn which_reuse() -> Option<()> { .filter(|o| o.status.success()) .map(|_| ()) } + +/// Build a nested-metadata fixture: optional root/child `REUSE.toml` +/// documents over `sub/f.rs` with the given bytes. +fn matrix_fixture(root_doc: Option<&str>, sub_doc: Option<&str>, file: &str) -> Fixture { + let f = Fixture::new(); + f.config("[default]\nlicense=\"PLACEHOLDER\"\n"); + if let Some(doc) = root_doc { + f.write("REUSE.toml", doc); + } + if let Some(doc) = sub_doc { + f.write("sub/REUSE.toml", doc); + } + std::fs::create_dir_all(f.path().join("sub")).unwrap(); + std::fs::write(f.path().join("sub/f.rs"), file).unwrap(); + f.commit("init"); + f +} + +fn check_file(f: &Fixture, intent: &str) -> serde_json::Value { + std::fs::write( + f.path().join("license.toml"), + format!("[default]\nlicense=\"{intent}\"\n"), + ) + .unwrap(); + let out = f + .licet() + .args(["check", "--format", "json", "--files", "sub/f.rs"]) + .output() + .unwrap(); + serde_json::from_slice(&out.stdout).unwrap() +} + +fn file_entry(v: &serde_json::Value) -> &serde_json::Value { + v["files"] + .as_array() + .unwrap() + .iter() + .find(|e| e["path"] == "sub/f.rs") + .expect("sub/f.rs in report") +} + +#[test] +fn precedence_matrix_closest_and_file() { + // Row 1: root closest A, file B → B wins (fallback unused). + let f = matrix_fixture( + Some( + "version = 1\n[[annotations]]\npath = \"sub/f.rs\"\nSPDX-License-Identifier = \"MIT\"\n", + ), + None, + "// SPDX-License-Identifier: Apache-2.0\nfn x(){}\n", + ); + let v = check_file(&f, "Apache-2.0"); + assert_eq!(file_entry(&v)["drift"], "compliant"); + assert_eq!(file_entry(&v)["actual"], "Apache-2.0"); +} + +#[test] +fn precedence_matrix_nested_closest() { + // Row 2: root closest A, child closest B, bare file → B (nearest fallback). + // The root supplies the copyright fallback, so both tables contribute. + let f = matrix_fixture( + Some( + "version = 1\n[[annotations]]\npath = \"sub/f.rs\"\nSPDX-License-Identifier = \"MIT\"\nSPDX-FileCopyrightText = \"2026 Root\"\n", + ), + Some( + "version = 1\n[[annotations]]\npath = \"f.rs\"\nSPDX-License-Identifier = \"Apache-2.0\"\n", + ), + "fn x(){}\n", + ); + let v = check_file(&f, "Apache-2.0"); + assert_eq!(file_entry(&v)["drift"], "compliant"); + assert_eq!(file_entry(&v)["actual"], "Apache-2.0"); + // Both contributing tables are explained with provenance, shallowest first. + let origins = file_entry(&v)["metadata_origins"].as_array().unwrap(); + assert_eq!(origins.len(), 2); + assert_eq!(origins[0]["metadata"], "REUSE.toml"); + assert_eq!(origins[0]["copyrights"], serde_json::json!(["2026 Root"])); + assert_eq!(origins[1]["metadata"], "sub/REUSE.toml"); + assert_eq!(origins[1]["licenses"], serde_json::json!(["Apache-2.0"])); +} + +#[test] +fn noncontributing_closest_table_has_no_provenance() { + // A closest table that loses the per-field fallback race contributes + // nothing, so it is excluded from the provenance explanation. + let f = matrix_fixture( + Some( + "version = 1\n[[annotations]]\npath = \"sub/f.rs\"\nSPDX-License-Identifier = \"MIT\"\n", + ), + Some( + "version = 1\n[[annotations]]\npath = \"f.rs\"\nSPDX-License-Identifier = \"Apache-2.0\"\n", + ), + "fn x(){}\n", + ); + let v = check_file(&f, "Apache-2.0"); + let origins = file_entry(&v)["metadata_origins"].as_array().unwrap(); + assert_eq!(origins.len(), 1); + assert_eq!(origins[0]["metadata"], "sub/REUSE.toml"); +} + +#[test] +fn precedence_matrix_child_override_suppresses_file() { + // Row 3: root closest A, child override B, complete file C → B governs; + // the file's own license is suppressed, the root stays as fallback. + let f = matrix_fixture( + Some( + "version = 1\n[[annotations]]\npath = \"sub/f.rs\"\nSPDX-License-Identifier = \"MIT\"\nSPDX-FileCopyrightText = \"2026 Root\"\n", + ), + Some( + "version = 1\n[[annotations]]\npath = \"f.rs\"\nprecedence = \"override\"\nSPDX-License-Identifier = \"Apache-2.0\"\n", + ), + "// SPDX-License-Identifier: GPL-2.0-only\n// SPDX-FileCopyrightText: 2026 File\nfn x(){}\n", + ); + // Policy equality joins all effective expressions: barrier Apache plus + // the root fallback MIT. + let v = check_file(&f, "Apache-2.0 AND MIT"); + assert_eq!(file_entry(&v)["drift"], "compliant"); + assert_eq!(file_entry(&v)["actual"], "Apache-2.0"); + // A lone Apache intent no longer covers the combination, and the + // suppressed file license satisfies nothing. + for intent in ["Apache-2.0", "GPL-2.0-only"] { + let v = check_file(&f, intent); + assert_eq!(file_entry(&v)["drift"], "wrong_license", "intent {intent}"); + } +} + +#[test] +fn precedence_matrix_aggregate_adds_to_file() { + // Rows 4–5: a parent aggregate always contributes; the closest table is + // fallback-only. With a file license C present, C stays primary. + let f = matrix_fixture( + Some( + "version = 1\n[[annotations]]\npath = \"sub/f.rs\"\nprecedence = \"aggregate\"\nSPDX-License-Identifier = \"MIT\"\n", + ), + Some( + "version = 1\n[[annotations]]\npath = \"f.rs\"\nSPDX-License-Identifier = \"Apache-2.0\"\n", + ), + "// SPDX-License-Identifier: GPL-2.0-only\nfn x(){}\n", + ); + let v = check_file(&f, "GPL-2.0-only AND MIT"); + assert_eq!(file_entry(&v)["drift"], "compliant"); + assert_eq!(file_entry(&v)["actual"], "GPL-2.0-only"); +} + +#[test] +fn precedence_matrix_aggregate_without_file() { + // Row 5: aggregate A + closest B over a bare file → A primary, B fallback. + let f = matrix_fixture( + Some( + "version = 1\n[[annotations]]\npath = \"sub/f.rs\"\nprecedence = \"aggregate\"\nSPDX-License-Identifier = \"MIT\"\n", + ), + Some( + "version = 1\n[[annotations]]\npath = \"f.rs\"\nSPDX-License-Identifier = \"Apache-2.0\"\n", + ), + "fn x(){}\n", + ); + let v = check_file(&f, "MIT AND Apache-2.0"); + assert_eq!(file_entry(&v)["drift"], "compliant"); + assert_eq!(file_entry(&v)["actual"], "MIT"); +} + +#[test] +fn precedence_matrix_rootmost_override_wins() { + // Row 6: two overrides, rootmost A governs; B and the file are suppressed. + let f = matrix_fixture( + Some( + "version = 1\n[[annotations]]\npath = \"sub/f.rs\"\nprecedence = \"override\"\nSPDX-License-Identifier = \"MIT\"\n", + ), + Some( + "version = 1\n[[annotations]]\npath = \"f.rs\"\nprecedence = \"override\"\nSPDX-License-Identifier = \"Apache-2.0\"\n", + ), + "// SPDX-License-Identifier: GPL-2.0-only\nfn x(){}\n", + ); + let v = check_file(&f, "MIT"); + assert_eq!(file_entry(&v)["drift"], "compliant"); + assert_eq!(file_entry(&v)["actual"], "MIT"); + let v = check_file(&f, "Apache-2.0"); + assert_eq!(file_entry(&v)["drift"], "wrong_license"); +} + +#[test] +fn precedence_matrix_child_aggregate_adds_to_file() { + // Row 7: root closest A, child aggregate B, file C → C primary, B added. + let f = matrix_fixture( + Some( + "version = 1\n[[annotations]]\npath = \"sub/f.rs\"\nSPDX-License-Identifier = \"MIT\"\n", + ), + Some( + "version = 1\n[[annotations]]\npath = \"f.rs\"\nprecedence = \"aggregate\"\nSPDX-License-Identifier = \"Apache-2.0\"\n", + ), + "// SPDX-License-Identifier: GPL-2.0-only\nfn x(){}\n", + ); + let v = check_file(&f, "GPL-2.0-only AND Apache-2.0"); + assert_eq!(file_entry(&v)["drift"], "compliant"); + assert_eq!(file_entry(&v)["actual"], "GPL-2.0-only"); +} + +#[test] +fn precedence_matrix_sidecar_beats_source() { + // Row 8: a `.license` sidecar carries the file's licensing information in + // place of the source header (REUSE 3.3 §"Order of precedence"). + let f = Fixture::new(); + f.config("[default]\nlicense=\"PLACEHOLDER\"\n") + .write( + "a.rs", + "// SPDX-License-Identifier: GPL-2.0-only\nfn a(){}\n", + ) + .write( + "a.rs.license", + "SPDX-License-Identifier: Apache-2.0\nSPDX-FileCopyrightText: 2026 Acme\n", + ) + .commit("init"); + std::fs::write( + f.path().join("license.toml"), + "[default]\nlicense=\"Apache-2.0\"\n", + ) + .unwrap(); + let out = f + .licet() + .args(["check", "--format", "json", "--files", "a.rs"]) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let entry = v["files"] + .as_array() + .unwrap() + .iter() + .find(|e| e["path"] == "a.rs") + .unwrap(); + assert_eq!(entry["drift"], "compliant"); + assert_eq!(entry["actual"], "Apache-2.0"); +} + +#[test] +fn root_copyright_only_override_leaves_license_missing() { + // An override that omits the license suppresses the file's license without + // reopening it: the file is missing a license even though its header names + // one, while copyright still validates from the file in task-5 lint. + let f = matrix_fixture( + Some( + "version = 1\n[[annotations]]\npath = \"sub/f.rs\"\nprecedence = \"override\"\nSPDX-FileCopyrightText = \"2026 Root\"\n", + ), + None, + "// SPDX-License-Identifier: Apache-2.0\n// SPDX-FileCopyrightText: 2026 File\nfn x(){}\n", + ); + let v = check_file(&f, "MIT"); + assert_eq!(file_entry(&v)["drift"], "missing_header"); +} + +#[test] +fn parent_aggregate_survives_child_override() { + // Parent aggregate A is farther than the child override barrier, so it is + // retained; the file itself is suppressed. + let f = matrix_fixture( + Some( + "version = 1\n[[annotations]]\npath = \"sub/f.rs\"\nprecedence = \"aggregate\"\nSPDX-License-Identifier = \"MIT\"\n", + ), + Some( + "version = 1\n[[annotations]]\npath = \"f.rs\"\nprecedence = \"override\"\nSPDX-License-Identifier = \"Apache-2.0\"\n", + ), + "// SPDX-License-Identifier: GPL-2.0-only\nfn x(){}\n", + ); + let v = check_file(&f, "Apache-2.0 AND MIT"); + assert_eq!(file_entry(&v)["drift"], "compliant", "combination covers"); + // Neither half of the combination covers it alone, and the suppressed + // file license satisfies nothing. + for intent in ["MIT", "Apache-2.0", "GPL-2.0-only"] { + let v = check_file(&f, intent); + assert_eq!(file_entry(&v)["drift"], "wrong_license", "intent {intent}"); + } +} + +#[test] +fn staged_nested_reuse_toml_reads_index_bytes() { + // Nested metadata participates in the staged snapshot: prefetched index + // blobs, never the working copy. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + .write("sub/f.rs", "// SPDX-License-Identifier: MIT\n") + .write( + "sub/REUSE.toml", + "version = 1\n[[annotations]]\npath = \"f.rs\"\nprecedence = \"override\"\nSPDX-License-Identifier = \"MIT\"\n", + ) + .commit("base"); + f.write( + "sub/REUSE.toml", + "version = 1\n[[annotations]]\npath = \"f.rs\"\nprecedence = \"override\"\nSPDX-License-Identifier = \"Apache-2.0\"\n", + ) + .stage_all(); + f.write( + "sub/REUSE.toml", + "version = 1\n[[annotations]]\npath = \"f.rs\"\nprecedence = \"override\"\nSPDX-License-Identifier = \"MIT\"\n", + ); + let out = f + .licet() + .args(["check", "--staged", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!( + report["files"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["path"] == "sub/f.rs" && entry["actual"] == "Apache-2.0"), + "staged nested annotation governs: {}", + String::from_utf8_lossy(&out.stdout) + ); +} + +#[test] +fn license_array_covers_binary_and_inventories_both_texts() { + // One table with two expressions covers a binary asset; both expressions + // are effective and both texts are inventoried. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT AND Apache-2.0\"\n"); + std::fs::write(f.path().join("logo.png"), [0xffu8, 0xfe, 0x00, 0x41]).unwrap(); + f.write( + "REUSE.toml", + "version = 1\n[[annotations]]\npath = \"logo.png\"\nprecedence = \"aggregate\"\n\ + SPDX-License-Identifier = [\"MIT\", \"Apache-2.0\"]\nSPDX-FileCopyrightText = \"2026 Acme\"\n", + ) + .commit("init"); + let out = f + .licet() + .args(["check", "--format", "json", "--files", "logo.png"]) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let entry = v["files"] + .as_array() + .unwrap() + .iter() + .find(|e| e["path"] == "logo.png") + .unwrap(); + assert_eq!(entry["drift"], "compliant", "{v:?}"); + // Both expressions are inventoried even though the binary has no header. + let out = f + .licet() + .args(["lint", "--format", "json"]) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let ids: Vec<&str> = v["license_texts"]["referenced"] + .as_array() + .unwrap() + .iter() + .filter_map(|x| x.as_str()) + .collect(); + assert!(ids.contains(&"MIT"), "MIT inventoried: {ids:?}"); + assert!( + ids.contains(&"Apache-2.0"), + "second array expression inventoried: {ids:?}" + ); + assert!( + v["license_texts"]["missing"] + .as_array() + .unwrap() + .iter() + .any(|x| x == "Apache-2.0"), + "missing second text is reported" + ); +} + +#[test] +fn dep5_aggregates_with_file_header() { + // Legacy dep5 adds its license to the file header's (REUSE 3.3 §"Order of + // precedence"): the file stays primary and both texts are inventoried. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT AND Apache-2.0\"\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .write( + ".reuse/dep5", + "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\ + \n\ + Files: a.rs\nCopyright: 2026 Acme\nLicense: Apache-2.0\n", + ) + .commit("init"); + let out = f + .licet() + .args(["check", "--format", "json", "--files", "a.rs"]) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let entry = v["files"] + .as_array() + .unwrap() + .iter() + .find(|e| e["path"] == "a.rs") + .unwrap(); + assert_eq!(entry["drift"], "compliant", "{v:?}"); + assert_eq!(entry["actual"], "MIT"); + assert_eq!(entry["actual_source"], "header"); + let out = f + .licet() + .args(["lint", "--format", "json"]) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let ids: Vec<&str> = v["license_texts"]["referenced"] + .as_array() + .unwrap() + .iter() + .filter_map(|x| x.as_str()) + .collect(); + assert!( + ids.contains(&"Apache-2.0"), + "dep5 license inventoried: {ids:?}" + ); +} + +#[test] +fn malformed_reuse_metadata_fails_before_writes() { + // Every malformed-metadata shape fails with exit 2 naming the document, + // and `apply` changes no file bytes. + let cases: &[(&str, &str)] = &[ + ( + "malformed-toml", + "version = 1\n[[annotations]]\npath = \nSPDX-License-Identifier = \"MIT\"\n", + ), + ( + "version-2", + "version = 2\n[[annotations]]\npath = \"a.rs\"\n", + ), + ( + "missing-version", + "[[annotations]]\npath = \"a.rs\"\nSPDX-License-Identifier = \"MIT\"\n", + ), + ( + "missing-path", + "version = 1\n[[annotations]]\nSPDX-License-Identifier = \"MIT\"\n", + ), + ( + "bad-precedence", + "version = 1\n[[annotations]]\npath = \"a.rs\"\nprecedence = \"bogus\"\nSPDX-License-Identifier = \"MIT\"\n", + ), + ( + "bad-expression", + "version = 1\n[[annotations]]\npath = \"a.rs\"\nSPDX-License-Identifier = \"NOT-A-LICENSE\"\n", + ), + ]; + for (name, doc) in cases { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .write("REUSE.toml", doc) + .commit("init"); + let before = f.read("a.rs"); + let out = f.licet().args(["apply", "--allow-dirty"]).output().unwrap(); + assert_eq!(out.status.code(), Some(2), "{name}: exit 2"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("REUSE.toml"), + "{name}: error names the document: {stderr}" + ); + assert_eq!(f.read("a.rs"), before, "{name}: no writes happen"); + assert_eq!(f.read("REUSE.toml"), *doc, "{name}: metadata untouched"); + } +} + +#[test] +fn apply_never_creates_reuse_toml_beside_dep5() { + // With only `.reuse/dep5` present, a fix that would need a REUSE.toml + // annotation is reported as unfixable (nonzero) instead of creating the + // mutually-exclusive document. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n") + .write( + ".reuse/dep5", + "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n", + ) + .commit("init"); + std::fs::write(f.path().join("logo.png"), [0xffu8, 0xfe, 0x00, 0x41]).unwrap(); + let out = f + .licet() + .args([ + "apply", + "--allow-dirty", + "--non-annotatable", + "reuse-toml", + "--files", + "logo.png", + ]) + .output() + .unwrap(); + assert!(!out.status.success()); + assert!( + !f.path().join("REUSE.toml").exists(), + "no REUSE.toml may be created beside .reuse/dep5" + ); +} + +#[test] +fn lint_requires_copyright_independently_of_license_intent() { + let f = Fixture::new(); + f.config("# SPDX-License-Identifier: MIT\n# SPDX-FileCopyrightText: 2026 Test\n[default]\nlicense=\"MIT\"\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\n") + .write("LICENSES/MIT.txt", licet::spdx::bundled_text("MIT").unwrap()); + let out = f + .licet() + .args(["lint", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(report["summary"]["pass"], false); + assert!( + out.stdout + .windows(b"missing_copyright".len()) + .any(|bytes| bytes == b"missing_copyright") + ); +} + +fn lint_json(f: &Fixture) -> (i32, serde_json::Value) { + let out = f + .licet() + .args(["lint", "--format", "json"]) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + (out.status.code().unwrap(), v) +} + +fn warning_kinds(v: &serde_json::Value) -> Vec { + v["diagnostics"] + .as_array() + .unwrap() + .iter() + .filter_map(|w| w["code"].as_str().map(str::to_string)) + .collect() +} + +#[test] +fn lint_succeeds_without_any_config_when_metadata_complete() { + // REUSE validation is declaration-independent: no license.toml at all. + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 Acme\nfn a(){}\n", + ) + .write( + "LICENSES/MIT.txt", + licet::spdx::bundled_text("MIT").unwrap(), + ); + let (code, report) = lint_json(&f); + assert_eq!(code, 0, "complete metadata needs no declaration: {report}"); +} + +#[test] +fn lint_empty_known_text_is_accepted() { + // An empty but correctly named text satisfies presence (the reference + // tool likewise reports no finding for it): licet never claims to prove + // legal correctness of file contents. + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 Acme\nfn a(){}\n", + ) + .write("LICENSES/MIT.txt", ""); + let (code, report) = lint_json(&f); + assert_eq!(code, 0, "{report}"); +} + +#[test] +fn lint_unused_text_fails_with_precise_set() { + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 Acme\nfn a(){}\n", + ) + .write( + "LICENSES/MIT.txt", + licet::spdx::bundled_text("MIT").unwrap(), + ) + .write( + "LICENSES/Apache-2.0.txt", + licet::spdx::bundled_text("Apache-2.0").unwrap(), + ); + let (code, report) = lint_json(&f); + assert_eq!(code, 1); + assert!(warning_kinds(&report).contains(&"unused_license_text".to_string())); + assert_eq!( + report["license_texts"]["unused"], + serde_json::json!(["Apache-2.0"]) + ); + assert_eq!(report["license_texts"]["missing"], serde_json::json!([])); +} + +#[test] +fn lint_unknown_file_is_bad_text() { + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 Acme\nfn a(){}\n", + ) + .write( + "LICENSES/MIT.txt", + licet::spdx::bundled_text("MIT").unwrap(), + ) + .write("LICENSES/Unknown-Thing.txt", "mystery\n"); + let (code, report) = lint_json(&f); + assert_eq!(code, 1); + assert!(warning_kinds(&report).contains(&"bad_license_text".to_string())); + assert_eq!( + report["license_texts"]["unrecognized"], + serde_json::json!(["LICENSES/Unknown-Thing.txt"]) + ); +} + +#[test] +fn lint_extensionless_text_reports_missing_extension() { + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 Acme\nfn a(){}\n", + ) + .write("LICENSES/MIT", licet::spdx::bundled_text("MIT").unwrap()); + let (code, report) = lint_json(&f); + assert_eq!(code, 1, "strict lint reports the missing extension"); + assert!(warning_kinds(&report).contains(&"missing_license_extension".to_string())); + assert_eq!(report["license_texts"]["missing"], serde_json::json!([])); + // ...while policy check still recognizes the text. + f.config("[default]\nlicense=\"MIT\"\n"); + let out = f + .licet() + .args(["check", "--files", "a.rs"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(0)); +} + +#[test] +fn lint_duplicate_texts_are_usage_error() { + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 Acme\nfn a(){}\n", + ) + .write( + "LICENSES/MIT.txt", + licet::spdx::bundled_text("MIT").unwrap(), + ) + .write("LICENSES/MIT.md", licet::spdx::bundled_text("MIT").unwrap()); + let out = f + .licet() + .args(["lint", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("MIT"), "names the duplicated id: {stderr}"); +} + +#[test] +fn lint_undecodable_text_is_incomplete_not_violation() { + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 Acme\nfn a(){}\n", + ); + std::fs::create_dir_all(f.path().join("LICENSES")).unwrap(); + std::fs::write(f.path().join("LICENSES/MIT.txt"), [0xffu8, 0xfe]).unwrap(); + let (code, report) = lint_json(&f); + assert_eq!(code, 1); + assert!(warning_kinds(&report).contains(&"unsupported_encoding".to_string())); + assert_eq!(report["summary"]["pass"], false); +} + +#[test] +fn lint_requires_exception_texts() { + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0\n// SPDX-FileCopyrightText: 2026 Acme\nfn a(){}\n", + ) + .write("LICENSES/GPL-2.0-only.txt", "gpl\n"); + let (code, report) = lint_json(&f); + assert_eq!(code, 1); + assert_eq!( + report["license_texts"]["missing"], + serde_json::json!(["Classpath-exception-2.0"]) + ); +} + +#[test] +fn lint_explicit_config_missing_or_malformed_is_usage_error() { + let f = Fixture::new(); + f.write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 Acme\nfn a(){}\n", + ) + .write( + "LICENSES/MIT.txt", + licet::spdx::bundled_text("MIT").unwrap(), + ); + for cfg in ["does/not-exist.toml", "license.toml"] { + if cfg == "license.toml" { + f.write("license.toml", "[default\nbroken\n"); + } + let out = f.licet().args(["lint", "--config", cfg]).output().unwrap(); + assert_eq!(out.status.code(), Some(2), "explicit config {cfg}"); + } +} + +#[test] +fn lint_explicit_valid_config_is_accepted_but_ignored() { + let f = Fixture::new(); + // The config file itself is a covered file, so it carries its own tags. + f.config("# SPDX-License-Identifier: MIT\n# SPDX-FileCopyrightText: 2026 Acme\n[default]\nlicense=\"Apache-2.0\"\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 Acme\nfn a(){}\n") + .write("LICENSES/MIT.txt", licet::spdx::bundled_text("MIT").unwrap()); + // The declared Apache intent is irrelevant: actual MIT metadata validates. + let out = f + .licet() + .args(["lint", "--format", "json", "--config", "license.toml"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(0)); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("deprecat"), "deprecation notice: {stderr}"); +} + +#[test] +fn lint_annotated_but_malformed_policy_toml_is_covered_file() { + // An auto-discovered license.toml that fails to parse is not a lint + // blocker: with its own SPDX tags it validates like any covered file. + let f = Fixture::new(); + f.write( + "license.toml", + "# SPDX-License-Identifier: MIT\n# SPDX-FileCopyrightText: 2026 Acme\n[default\nbroken\n", + ) + .write( + "a.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 Acme\nfn a(){}\n", + ) + .write( + "LICENSES/MIT.txt", + licet::spdx::bundled_text("MIT").unwrap(), + ); + let (code, _) = lint_json(&f); + assert_eq!(code, 0); +} + +#[test] +fn reuse_and_dep5_coexistence_fails_before_writes() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .write( + "REUSE.toml", + "version = 1\n[[annotations]]\npath = \"a.rs\"\nSPDX-License-Identifier = \"MIT\"\n", + ) + .write( + ".reuse/dep5", + "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n", + ) + .commit("init"); + let out = f + .licet() + .args(["check", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("REUSE.toml") && stderr.contains(".reuse/dep5"), + "error names both documents: {stderr}" + ); +} + +/// Task 8 round-trip: `init` must preserve every observed effective license — +/// in-file headers, an extensionless exception, a sidecar-covered binary, and +/// nested REUSE coverage — while leaving the unlicensed file uncovered. +/// Projection is asserted behaviorally (per-file drift/actual through the real +/// binary), never by substring presence in the generated TOML. +#[test] +fn init_round_trip_preserves_all_observed_licensing() { + let f = Fixture::new(); + f.write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .write("b.rs", "// SPDX-License-Identifier: MIT\nfn b(){}\n") + .write("c.rs", "// SPDX-License-Identifier: Apache-2.0\nfn c(){}\n") + .write("NOTICE", "SPDX-License-Identifier: BSD-3-Clause\nnotes\n") + .write( + "asset.bin.license", + "SPDX-License-Identifier: MIT\nSPDX-FileCopyrightText: 2026 Test\n", + ) + .write( + "sub/REUSE.toml", + "version = 1\n[[annotations]]\npath = \"covered.dat\"\nSPDX-License-Identifier = \"ISC\"\n", + ) + .write("sub/covered.dat", "opaque\n") + .write("todo.py", "x = 1\n") + .texts(&["MIT", "Apache-2.0", "BSD-3-Clause", "ISC"]); + std::fs::write(f.path().join("asset.bin"), [0x00, 0xFF, 0x89, 0x50]).unwrap(); + f.commit("init"); + + // Generate to a fresh output, then promote it to the policy config. + let out = f + .licet() + .args(["init", "--output", "gen.toml"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "init succeeds: {}", + String::from_utf8_lossy(&out.stderr) + ); + std::fs::copy(f.path().join("gen.toml"), f.path().join("license.toml")).unwrap(); + + let out = f + .licet() + .args(["check", "--format", "json"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(1), + "unknown file fails the gate: {}", + String::from_utf8_lossy(&out.stderr) + ); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let drift_of = |name: &str| { + report["files"] + .as_array() + .unwrap() + .iter() + .find(|e| e["path"] == name) + .unwrap_or_else(|| panic!("{name} must be reported"))["drift"] + .as_str() + .unwrap() + .to_string() + }; + let actual_of = |name: &str| { + report["files"] + .as_array() + .unwrap() + .iter() + .find(|e| e["path"] == name) + .unwrap()["actual"] + .as_str() + .unwrap() + .to_string() + }; + // Every known effective license is equal before/after generation. + for (name, lic) in [ + ("a.rs", "MIT"), + ("b.rs", "MIT"), + ("c.rs", "Apache-2.0"), + ("NOTICE", "BSD-3-Clause"), + ("asset.bin", "MIT"), + ("sub/covered.dat", "ISC"), + ] { + assert_eq!(drift_of(name), "compliant", "{name} keeps its license"); + assert_eq!(actual_of(name), lic, "{name} effective license preserved"); + } + // The unlicensed file remains uncovered — no default was invented for it. + assert_eq!(drift_of("todo.py"), "uncovered"); +} + +/// `init` creates new by default and refuses to overwrite without `--force`; +/// `--force` replaces exactly the observed bytes. A failure changes neither +/// sources nor the existing config. +#[test] +fn init_refuses_overwrite_without_force() { + let f = Fixture::new(); + f.write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .commit("init"); + + let out = f.licet().arg("init").output().unwrap(); + assert_eq!(out.status.code(), Some(0)); + let first = f.read("license.toml"); + + let out = f.licet().arg("init").output().unwrap(); + assert_eq!( + out.status.code(), + Some(2), + "second init refuses without --force" + ); + assert_eq!(f.read("license.toml"), first, "existing config untouched"); + assert_eq!( + f.read("a.rs"), + "// SPDX-License-Identifier: MIT\nfn a(){}\n", + "source untouched" + ); + + let out = f.licet().args(["init", "--force"]).output().unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "--force regenerates: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + f.read("license.toml"), + first, + "identical intent → identical bytes" + ); +} + +/// `init --format json` joins the v2 envelope: one document on stdout with the +/// config write record and unknown paths as diagnostics. +#[test] +fn init_json_joins_report_envelope() { + let f = Fixture::new(); + f.write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .write("todo.py", "x = 1\n") + .commit("init"); + + let out = f + .licet() + .args(["init", "--format", "json", "--output", "gen.toml"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(0)); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(report["version"], 2); + assert_eq!(report["command"], "init"); + assert_eq!(report["summary"]["pass"], true); + let writes = report["writes"].as_array().unwrap(); + assert_eq!(writes.len(), 1); + assert_eq!(writes[0]["kind"], "config"); + assert_eq!(writes[0]["status"], "applied"); + assert!(writes[0]["after_text"].as_str().unwrap().contains("MIT")); + let diagnostics = report["diagnostics"].as_array().unwrap(); + assert!( + diagnostics + .iter() + .any(|d| d["path"] == "todo.py" && d["code"] == "missing_license"), + "unknown path reported: {diagnostics:?}" + ); +} + +/// A symlinked destination is never followed or truncated, even with `--force`. +#[cfg(unix)] +#[test] +fn init_never_writes_through_symlink_destination() { + use std::os::unix::fs::symlink; + let f = Fixture::new(); + f.write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .write("real.toml", "SENTINEL = 1\n") + .commit("init"); + symlink(f.path().join("real.toml"), f.path().join("link.toml")).unwrap(); + + let out = f + .licet() + .args(["init", "--force", "--output", "link.toml"]) + .output() + .unwrap(); + assert_ne!(out.status.code(), Some(0), "symlink destination refused"); + assert_eq!( + f.read("real.toml"), + "SENTINEL = 1\n", + "link target untouched" + ); +} + +/// Paths with quotes survive generation: TOML escaping round-trips and the +/// projection still verifies (Unix-only: quotes are illegal on Windows). +#[cfg(unix)] +#[test] +fn init_handles_quote_paths() { + let f = Fixture::new(); + f.write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .write("we\"ird.rs", "// SPDX-License-Identifier: MIT\nfn w(){}\n") + .commit("init"); + + let out = f + .licet() + .args(["init", "--output", "gen.toml"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "quoted path generates: {}", + String::from_utf8_lossy(&out.stderr) + ); + std::fs::copy(f.path().join("gen.toml"), f.path().join("license.toml")).unwrap(); + let out = f + .licet() + .args(["check", "--format", "json", "--files", "we\"ird.rs"]) + .output() + .unwrap(); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(report["files"][0]["drift"], "compliant"); +} // REUSE-IgnoreEnd diff --git a/tests/us5_sidecars.rs b/tests/us5_sidecars.rs index 4ee244d..c1b9214 100644 --- a/tests/us5_sidecars.rs +++ b/tests/us5_sidecars.rs @@ -132,9 +132,10 @@ fn cli_flag_overrides_config_strategy() { #[test] fn reuse_toml_override_wrong_license_is_fixed_in_place() { - // The previously-documented residual edge: a non-annotatable file already covered by a - // REUSE.toml `override` entry whose license is wrong. `apply` now rewrites that entry's - // license in place rather than telling the user to fix it by hand. + // A non-annotatable file already covered by a REUSE.toml `override` entry + // whose license is wrong. `apply` appends a superseding `override` stanza + // (last match wins) rather than rewriting the stale entry in place, so the + // original document survives byte-for-byte and a rerun converges. let f = Fixture::new(); f.config("[default]\nlicense=\"MIT\"\n[[rule]]\nfile=\"logo.png\"\nlicense=\"CC-BY-4.0\"\n"); std::fs::write(f.path().join("logo.png"), PNG).unwrap(); @@ -156,25 +157,22 @@ fn reuse_toml_override_wrong_license_is_fixed_in_place() { String::from_utf8_lossy(&out.stdout) ); - // No sidecar written; the REUSE.toml entry itself was corrected in place. + // No sidecar written; the stale stanza is untouched and the appended + // `override` stanza governs. assert!(!f.path().join("logo.png.license").exists()); let reuse = f.read("REUSE.toml"); assert!( reuse.contains("SPDX-License-Identifier = \"CC-BY-4.0\""), "{reuse}" ); - assert!( - !reuse.contains("MIT"), - "stale license must be gone: {reuse}" - ); assert_eq!( reuse.matches("[[annotations]]").count(), - 1, - "no duplicate block: {reuse}" + 2, + "superseding stanza appended: {reuse}" ); assert!( - reuse.contains("precedence = \"override\""), - "precedence preserved: {reuse}" + reuse.contains("precedence = \"override\"\nSPDX-License-Identifier = \"CC-BY-4.0\""), + "appended stanza keeps the barrier: {reuse}" ); assert_eq!( @@ -239,4 +237,134 @@ fn reuse_toml_write_is_idempotent() { assert_eq!(first, second, "REUSE.toml entry duplicated on re-apply"); assert_eq!(first.matches("path = \"logo.png\"").count(), 1); } + +#[test] +fn additive_sidecar_preserves_old_identifiers() { + // Additive sidecar coverage keeps the old license lines and notices while + // adding the declared ones (FR-006). + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[[rule]]\nfile=\"logo.png\"\nlicense=\"CC-BY-4.0\"\n"); + std::fs::write(f.path().join("logo.png"), PNG).unwrap(); + f.write( + "logo.png.license", + "SPDX-FileCopyrightText: 2026 Acme\nSPDX-License-Identifier: MIT\n", + ); + f.commit("init"); + + let out = f + .licet() + .args(["apply", "--additive", "--files", "logo.png"]) + .output() + .unwrap(); + // The write succeeds (both identifiers land) but the old record still + // counts as drift: exit 1, not partial. + assert_eq!( + out.status.code(), + Some(1), + "apply output: {}", + String::from_utf8_lossy(&out.stdout) + ); + let sidecar = f.read("logo.png.license"); + assert!( + sidecar.contains("SPDX-License-Identifier: MIT"), + "old identifier kept: {sidecar:?}" + ); + assert!( + sidecar.contains("SPDX-License-Identifier: CC-BY-4.0"), + "declared identifier added: {sidecar:?}" + ); + assert!( + sidecar.contains("SPDX-FileCopyrightText: 2026 Acme"), + "notice kept: {sidecar:?}" + ); +} + +#[test] +fn override_without_license_leaves_file_missing_header() { + // An override stanza that carries no license is ineffective for + // licensing: the file still fails the gate with its own path, and apply + // fixes it where the coverage lives. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[[rule]]\nfile=\"logo.png\"\nlicense=\"CC-BY-4.0\"\n"); + std::fs::write(f.path().join("logo.png"), PNG).unwrap(); + f.write( + "REUSE.toml", + "version = 1\n\n[[annotations]]\npath = \"logo.png\"\nprecedence = \"override\"\n\ + SPDX-FileCopyrightText = \"2026 Acme\"\n", + ); + f.commit("init"); + + let out = f + .licet() + .args(["check", "--format", "json"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(1), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let entry = v["files"] + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .find(|e| e["path"] == "logo.png") + .expect("logo.png in report"); + // The binary carries no detectable license anywhere: without a usable + // license record the override is ineffective for licensing. + assert_eq!(entry["drift"], "unreadable"); + + let out = f.licet().arg("apply").output().unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + assert_eq!( + check_compliant(&f, "logo.png")["summary"]["counts"]["compliant"], + 1 + ); +} + +#[test] +fn toml_covered_source_file_gets_annotation_not_header() { + // Provenance picks the destination before any comment syntax: a source + // file already covered by REUSE.toml is fixed in the document, never with + // an in-file header — even though a comment style exists for it. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[[rule]]\nfile=\"a.rs\"\nlicense=\"Apache-2.0\"\n") + .write("a.rs", "fn a(){}\n") + .write( + "REUSE.toml", + "version = 1\n\n[[annotations]]\npath = \"a.rs\"\nSPDX-License-Identifier = \"MIT\"\n", + ) + .commit("init"); + + let out = f.licet().arg("apply").output().unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stdout) + ); + assert_eq!(f.read("a.rs"), "fn a(){}\n", "no in-file header inserted"); + let reuse = f.read("REUSE.toml"); + assert_eq!(reuse.matches("[[annotations]]").count(), 2, "{reuse}"); + assert!( + reuse.contains("SPDX-License-Identifier = \"Apache-2.0\""), + "{reuse}" + ); + + let check = f.licet().arg("check").output().unwrap(); + assert_eq!( + check.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&check.stdout) + ); +} // REUSE-IgnoreEnd diff --git a/tests/us5_snippets.rs b/tests/us5_snippets.rs index a14bde9..89a9074 100644 --- a/tests/us5_snippets.rs +++ b/tests/us5_snippets.rs @@ -17,6 +17,73 @@ fn check_json(f: &Fixture, file: &str) -> serde_json::Value { serde_json::from_slice(&out.stdout).unwrap() } +fn snippet_file(padding_lines: usize) -> String { + // A file whose snippet region sits `padding_lines` into the body (past the + // old 8 KiB head window when large): full-content scans still find it. + let mut s = String::from("// SPDX-License-Identifier: MIT\nfn f() {}\n"); + for i in 0..padding_lines { + s.push_str(&format!("// filler line {i}\n")); + } + s.push_str( + "// SPDX-SnippetBegin\n// SPDX-License-Identifier: BSD-3-Clause\nfn vendored() {}\n// SPDX-SnippetEnd\n", + ); + s +} + +#[test] +fn late_snippet_is_detected_and_inventoried() { + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n"); + f.write("src/lib.rs", &snippet_file(2000)).commit("init"); + let v = check_json(&f, "src/lib.rs"); + assert_eq!(v["summary"]["counts"]["compliant"], 1); + let out = f + .licet() + .args(["lint", "--format", "json"]) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let ids: Vec<&str> = v["license_texts"]["referenced"] + .as_array() + .unwrap() + .iter() + .filter_map(|x| x.as_str()) + .collect(); + assert!( + ids.contains(&"BSD-3-Clause"), + "late snippet referenced: {ids:?}" + ); +} + +#[test] +fn excluded_file_snippet_text_stays_required() { + // Declaration exclusion removes a file from policy drift (policy flows do + // not even read it), but lint ignores declaration exclusions: the snippet + // still exists in the tree, so its text stays required there. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"vendored.rs\"]\n"); + f.write( + "vendored.rs", + "// SPDX-License-Identifier: MIT\n// SPDX-FileCopyrightText: 2026 Acme\nfn f() {}\n// SPDX-SnippetBegin\n// SPDX-License-Identifier: BSD-3-Clause\nfn v() {}\n// SPDX-SnippetEnd\n", + ) + .write("LICENSES/MIT.txt", licet::spdx::bundled_text("MIT").unwrap()) + .commit("init"); + let out = f + .licet() + .args(["lint", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1), "snippet text still required"); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!( + v["license_texts"]["missing"] + .as_array() + .unwrap() + .iter() + .any(|x| x == "BSD-3-Clause") + ); +} + #[test] fn snippet_license_is_inventoried_but_not_file_drift() { // File is MIT; an embedded snippet is BSD-3-Clause. The file stays compliant on MIT, From 3d08c28ebf5449dc63ea5332db8bd126b4b212bb Mon Sep 17 00:00:00 2001 From: Adam Poulemanos Date: Fri, 11 Sep 2026 08:25:50 -0400 Subject: [PATCH 02/12] docs: attribute vendored REUSE Specification v3.3 (CC-BY-SA-4.0) docs/REUSE_Specification_v3.3.md is a verbatim copy of the FSFE REUSE Specification, whose upstream reuse-docs repository is licensed CC-BY-SA-4.0 (not CC-BY-4.0). Add the required attribution: FSFE copyright notice, SPDX identifier, source link, and a note that no REUSE-tool source (GPL-3.0-or-later) is included in this project. --- docs/REUSE_Specification_v3.3.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/REUSE_Specification_v3.3.md b/docs/REUSE_Specification_v3.3.md index 081302f..c376776 100644 --- a/docs/REUSE_Specification_v3.3.md +++ b/docs/REUSE_Specification_v3.3.md @@ -1,3 +1,8 @@ + + + # REUSE Specification – Version 3.3 From 54cb738bd99445575264f89f61cce38b1d4ff987 Mon Sep 17 00:00:00 2001 From: Adam Poulemanos Date: Fri, 11 Sep 2026 08:25:57 -0400 Subject: [PATCH 03/12] feat!: rename default config license.toml to licet.toml Names containing 'license' are claimed by license-detection heuristics (GitHub licensee, REUSE tooling), which misread the declarative config as a license text. The default config path is now /licet.toml everywhere (check/apply/lint/add-license discovery, init output, --config help), via config::CONFIG_FILENAME. Migration: 'mv license.toml licet.toml'. A lone legacy license.toml is never picked up by default - check/apply fail exit 2 naming the rename, and init refuses to write a competing default next to one. An explicit --config path still reads any filename as an escape hatch. Covers default load, legacy hint, and init paths with three new enforce tests; snapshots, living docs, and contracts updated. 393 tests passing (24 differential vs reuse 6.2.0). --- CLAUDE.md | 6 +- README.md | 12 +- benches/scan.rs | 10 +- .../contracts/cli.md | 20 ++-- .../contracts/config-schema.md | 11 +- .../data-model.md | 2 +- specs/001-declarative-license-headers/spec.md | 4 +- src/cli/add_license.rs | 6 +- src/cli/init.rs | 19 ++- src/cli/lint.rs | 6 +- src/cli/mod.rs | 11 +- src/config/mod.rs | 35 +++++- src/config/schema.rs | 4 +- src/walk/mod.rs | 19 ++- tests/common/mod.rs | 4 +- tests/perf.rs | 4 +- tests/report_contract.rs | 2 +- .../us1_snapshots__drift_report_human.snap | 2 +- ...us1_snapshots__uncovered_report_human.snap | 2 +- tests/us1_snapshots.rs | 2 +- tests/us2_apply.rs | 4 +- tests/us3_detect.rs | 10 +- tests/us4_enforce.rs | 111 ++++++++++++++---- tests/us5_add_license.rs | 8 +- tests/us5_reuse.rs | 32 ++--- 25 files changed, 226 insertions(+), 120 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6db8fcd..c3f995f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is `licet` is a single-binary Rust CLI that manages SPDX/REUSE-compatible license/copyright -headers across a repository from one declarative config (`license.toml`). You declare +headers across a repository from one declarative config (`licet.toml`). You declare intent once; `licet` projects it onto the working tree, classifies drift, and reconciles files to match. It is **offline and hermetic by default** — SPDX license texts are embedded in the binary at build time — and output stays compatible with the upstream @@ -74,7 +74,7 @@ content (destructive on the license id by default; `--additive` keeps both), cop `git checkout` is always a clean undo. Supporting modules: -- `src/config/` — `license.toml` loading/validation (`schema.rs` is the raw serde model; +- `src/config/` — `licet.toml` loading/validation (`schema.rs` is the raw serde model; `mod.rs` validates into the domain types). This is the **only** authoring surface for intent; `REUSE.toml`/dep5 are read for interop/detection only. - `src/comment/` — built-in comment-style registry (seeded to the REUSE-known set), @@ -106,7 +106,7 @@ and contracts live under `specs/001-declarative-license-headers/`: - `spec.md`, `data-model.md` — requirements and domain model (code comments cite `FR-0xx` / `data-model §x` tags that map back here). - `contracts/cli.md` — CLI surface and exit-code contract. -- `contracts/config-schema.md` — full `license.toml` schema. +- `contracts/config-schema.md` — full `licet.toml` schema. - `contracts/report.schema.json` — JSON output schema. When changing behavior, update the spec/contracts alongside the code, and keep the diff --git a/README.md b/README.md index 3f10aea..ccb8898 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A single-binary CLI that manages SPDX/REUSE-compatible license and copyright metadata for an entire repository from **one declarative configuration**. You declare intent once -in `license.toml`; `licet` projects that intent onto the working tree, reports drift, and +in `licet.toml`; `licet` projects that intent onto the working tree, reports drift, and reconciles files to match — destructive on the license identifier by default, additive opt-in, copyright always preserved. @@ -22,7 +22,7 @@ Requirements: Rust 1.89+. A git repository (default coverage = tracked files). ## Configure: declare intent once -`license.toml` at the repo root is the **only** authoring surface for licensing intent. +`licet.toml` at the repo root is the **only** authoring surface for licensing intent. Rules are ordered; specificity is `file` > `glob` > `ext`, with declaration order breaking ties. `REUSE.toml`/`.reuse/dep5` are read for interop/detection only. @@ -56,7 +56,7 @@ for the full schema. |---------|---------| | `licet check` | Non-writing gate: classify drift, exit pass/fail. | | `licet apply` | Reconcile files to declared intent (destructive by default). | -| `licet init` | Derive a `license.toml` from current repository state. | +| `licet init` | Derive a `licet.toml` from current repository state. | | `licet lint` | Report REUSE-compatibility posture & license-text completeness. | | `licet add-license` (alias `add`) | Materialize license texts into `LICENSES/` from the offline bundle. | @@ -91,7 +91,7 @@ never invented — the maintainer supplies them. ### `init` / `lint` ```bash -licet init --from-reuse # bootstrap license.toml from existing headers + REUSE.toml +licet init --from-reuse # bootstrap licet.toml from existing headers + REUSE.toml licet lint # LICENSES/ completeness, missing texts, SPDX list version licet --version # tool version + embedded SPDX license-list version ``` @@ -107,7 +107,7 @@ licet add-license --all # every referenced-but-missing text ``` Unlike `apply`, it writes **only** under `LICENSES/` — it never edits source files or -`license.toml`, so it does not require a clean working tree. `LicenseRef-*` ids are +`licet.toml`, so it does not require a clean working tree. `LicenseRef-*` ids are reported missing (exit `1`) and never scaffolded: custom texts come from the maintainer. A successful run writes exactly the requested text files, one per id. Exit `0` on success, `1` if a requested text can't be supplied offline, `2` on flag misuse @@ -126,7 +126,7 @@ Supported shells: `bash`, `zsh`, `fish`, `powershell`, `elvish`. ## Naming - **Binary**: `licet` (one self-contained executable; see [Install](#install)). -- **Config**: `license.toml` at the repo root — the sole authoring surface, overridable +- **Config**: `licet.toml` at the repo root — the sole authoring surface, overridable with `--config `. `REUSE.toml` / `.reuse/dep5` are read for interop only. - **No cache**: scans are stateless and never create files; `--cache` / `--no-cache` remain only as deprecated no-ops. diff --git a/benches/scan.rs b/benches/scan.rs index 2bca931..1f73f9f 100644 --- a/benches/scan.rs +++ b/benches/scan.rs @@ -18,11 +18,11 @@ use licet::engine::Engine; use licet::walk::Selection; use licet::walk::{Discovered, Purpose, prepare}; -const BASE_CONFIG: &str = "[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n"; +const BASE_CONFIG: &str = "[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n"; /// Populate `root` with `n` compliant tiny Rust files across subdirectories. fn build_tiny_tree(root: &Path, n: usize) { - std::fs::write(root.join("license.toml"), BASE_CONFIG).unwrap(); + std::fs::write(root.join("licet.toml"), BASE_CONFIG).unwrap(); for i in 0..n { let dir = root.join(format!("src/d{}", i / 100)); std::fs::create_dir_all(&dir).unwrap(); @@ -43,7 +43,7 @@ fn build_mixed_tree(root: &Path, n: usize) -> String { "[[rule]]\nglob=\"src/d{i}/**\"\nlicense=\"MIT\"\n" )); } - std::fs::write(root.join("license.toml"), &config).unwrap(); + std::fs::write(root.join("licet.toml"), &config).unwrap(); std::fs::create_dir_all(root.join("sub")).unwrap(); std::fs::write( root.join("sub/REUSE.toml"), @@ -99,7 +99,7 @@ fn prepared_paths( ) { let prep = prepare( root, - &root.join("license.toml"), + &root.join("licet.toml"), &Selection::FullTree, Purpose::Policy, false, @@ -111,7 +111,7 @@ fn prepared_paths( /// Assert the evaluated set is exactly what the timed loop will classify: /// same file count, every file compliant or excluded. Runs once, outside -/// timing. (Excluded files such as `license.toml` stay in the path set with +/// timing. (Excluded files such as `licet.toml` stay in the path set with /// an `Excluded` verdict — they are part of the equivalent set.) fn assert_equivalent(engine: &Engine, paths: &[Discovered]) { let result = engine.scan(paths).unwrap(); diff --git a/specs/001-declarative-license-headers/contracts/cli.md b/specs/001-declarative-license-headers/contracts/cli.md index d7f1ed9..76f3ee0 100644 --- a/specs/001-declarative-license-headers/contracts/cli.md +++ b/specs/001-declarative-license-headers/contracts/cli.md @@ -3,7 +3,7 @@ The tool is a single binary named `licet` (Latin "it is permitted" — the root of "license"). Text I/O contract: arguments → stdout for results, errors/diagnostics → stderr. Every command accepts -`--format human|json` (default `human`) and `--config ` (default `./license.toml`). +`--format human|json` (default `human`) and `--config ` (default `./licet.toml`). ## Exit codes @@ -11,7 +11,7 @@ arguments → stdout for results, errors/diagnostics → stderr. Every command a |------|---------|---------| | `0` | Success / fully compliant — no drift, no uncovered files | `check`, `apply`, `lint`, `add-license` | | `1` | Drift or violations found (non-compliant) | `check`, `lint`, `add-license` | -| `2` | Usage / configuration error (bad flags, invalid `license.toml`) | all | +| `2` | Usage / configuration error (bad flags, invalid config) | all | | `3` | Partial apply — some files changed, some failed (FR-021) | `apply` | `check` maps any of {WrongLicense, MissingHeader, Uncovered, Unreadable, unresolved @@ -43,7 +43,7 @@ expression) — raised before any directory is created or any download is attemp | Flag | Description | |------|-------------| -| `--config ` | Path to declarative config (default `./license.toml`). | +| `--config ` | Path to declarative config (default `./licet.toml`). A legacy `./license.toml` is not defaulted to (exit 2 names the rename); an explicit path reads any name. | | `--format human\|json` | Output rendering. `json` conforms to `report.schema.json`. | | `--files
…` / `--files-from ` / `-` (stdin) | Restrict evaluation to a supplied subset (FR-013). Paths are relative to the invocation cwd (or absolute), normalized lexically; outside-root and nonregular inputs are usage errors, symlinks are ignored like everywhere else. | | `--staged` | Restrict to git-staged files (commit-hook mode, FR-013). `check --staged` evaluates **index blobs** — including staged metadata, config, and license texts — so the gate sees the commit as it would land; `apply --staged` uses the staged **path set** but edits working-tree files and never stages its edits. | @@ -145,7 +145,7 @@ licet init [--from-reuse] [--output ] [--config ] [--force] [--forma observation with the real rule resolver before anything is written, and a validation failure writes nothing. - Does not modify source files; writes only the config. Destination: `--output`, - else explicit `--config`, else `/license.toml` (explicit relative paths + else explicit `--config`, else `/licet.toml` (explicit relative paths resolve from the invocation cwd; an explicit destination outside the project authorizes only that config file). Create-new is the default: an existing destination (file or symlink) is refused with exit `2` unless `--force` @@ -168,7 +168,7 @@ licet lint [--allow-network] [--config ] - Covers tracked **plus** nonignored untracked files in a repository (the policy gate's tracked-only default does not apply), and never applies declaration `[exclude]` rules — a config exclusion cannot hide a file from whole-project - REUSE validation. Needs no configuration: an auto-discovered `license.toml` is + REUSE validation. Needs no configuration: an auto-discovered `licet.toml` is just another covered file. An explicit `--config ` must exist and parse (exit `2` otherwise); it is accepted with a deprecation notice and never changes evaluation. @@ -209,7 +209,7 @@ licet add … never deletes or truncates the destination. Without it, an unbundled non-`LicenseRef` identifier cannot be supplied and the run exits `1`, naming it (consistent with `lint`). Bundled texts never invoke the network. -- Writes **only** under `LICENSES/`: it never modifies source files or `license.toml`, and +- Writes **only** under `LICENSES/`: it never modifies source files or `licet.toml`, and therefore — unlike `apply` — does **not** require a clean working tree. (`apply` still materializes texts as a side-effect of annotating; `add-license` exposes that materialization standalone, without touching headers.) @@ -247,7 +247,7 @@ licet add … - **Path bases**: explicit file arguments are relative to the invocation cwd; declaration selectors and metadata paths are relative to their documented base. An omitted `--config` resolves from the discovered root - (`/license.toml`); an explicit relative `--config` stays cwd-relative. + (`/licet.toml`); an explicit relative `--config` stays cwd-relative. - **Output streams**: `--format json` prints exactly one serialized document to stdout; progress, prompts, and human diagnostics go to stderr. The tool never prompts implicitly — automation never stalls; network happens only with an @@ -270,8 +270,10 @@ licet add … - Additive drift: `licet apply --additive` keeps old identifiers; when the combination still differs from intent it exits `1` with a residual-drift diagnostic — success of the writes, failure of the gate. -- Safe init: `licet init` refuses to overwrite `license.toml`; re-run with - `licet init --force`. +- Safe init: `licet init` refuses to overwrite `licet.toml`; re-run with + `licet init --force`. With no `--output`/`--config` and only a legacy + `license.toml` present, `init` refuses (exit `2`) instead of writing a + competing default — rename first. - Preview: `licet apply --dry-run` lists every planned/applied write with before/after text and exits nonzero when the projected gate fails — running the real `apply` afterwards produces those exact bytes. diff --git a/specs/001-declarative-license-headers/contracts/config-schema.md b/specs/001-declarative-license-headers/contracts/config-schema.md index 1f2503e..516373b 100644 --- a/specs/001-declarative-license-headers/contracts/config-schema.md +++ b/specs/001-declarative-license-headers/contracts/config-schema.md @@ -1,15 +1,22 @@ -# Configuration Contract: `license.toml` +# Configuration Contract: `licet.toml` The single declarative source of truth (FR-001). Parsed by `src/config` via `serde`/`toml` into the `LicensingConfiguration` entity (see `data-model.md`). Lives at repo root; path overridable with `--config`. -`license.toml` is the **only authoring surface** for licensing intent. `REUSE.toml`/ +`licet.toml` is the **only authoring surface** for licensing intent. `REUSE.toml`/ `.reuse/dep5` are read for interop and actual-license detection only (their REUSE 3.3 `precedence` decides detection when they disagree with an in-file header — `closest` by default, FR-003a); they are never hand-authored as the declarative config. This reflects the maintainer's view that `REUSE.toml`, while TOML, is not designed for declarative intent. +The file is named `licet.toml` rather than `license.toml` on purpose: names +containing `license` are claimed by license-detection heuristics (GitHub +licensee, REUSE tooling), which misread a declarative config as a license +text. A pre-rename `license.toml` is never picked up by default — commands +fail with a usage error naming the rename — but an explicit +`--config license.toml` still reads any named path. + ## Top-level structure ```toml diff --git a/specs/001-declarative-license-headers/data-model.md b/specs/001-declarative-license-headers/data-model.md index 6ad947a..d1cf2bb 100644 --- a/specs/001-declarative-license-headers/data-model.md +++ b/specs/001-declarative-license-headers/data-model.md @@ -24,7 +24,7 @@ Per-file pipeline: ## 1. LicensingConfiguration -The single declarative source of truth (FR-001). Loaded from `license.toml`. +The single declarative source of truth (FR-001). Loaded from `licet.toml`. | Field | Type | Notes | |-------|------|-------| diff --git a/specs/001-declarative-license-headers/spec.md b/specs/001-declarative-license-headers/spec.md index fa61d1e..1c3018e 100644 --- a/specs/001-declarative-license-headers/spec.md +++ b/specs/001-declarative-license-headers/spec.md @@ -27,7 +27,7 @@ This is a superset/replacement for the day-to-day workflow of the existing REUSE ### Session 2026-06-24 (specification review panel) - Q: CI checks out fresh, so the warm-cache target doesn't apply there. What is the cold-scan performance bar? → A: A full **cold** scan (empty cache) of a ~10,000-file repository completes in under **3 seconds** on a 4-core 2020-era runner; the sub-1-second figure is the **warm-cache** (repeat/local) target. -- Q: When a file's in-file SPDX header and an out-of-band entry (`REUSE.toml`/`.reuse/dep5`) disagree on the license, which is authoritative for detection? → A: It depends on the annotation's REUSE 3.3 **`precedence`** field. `closest` (the spec **default**) makes file-level info — the in-file header or its `.license` sidecar — authoritative, with the annotation a fallback; `override` makes the annotation authoritative and emits a non-failing `source_override` diagnostic; `aggregate` treats both as satisfying. `.reuse/dep5` has no `precedence` field and, per REUSE 3.3, is *aggregated* with file-level information. The declarative config (`license.toml`) remains the source of truth for *intent*; out-of-band is read for interop/detection only, never the authoring surface. (Note: the maintainer considers `REUSE.toml` poorly suited to declarative authoring.) +- Q: When a file's in-file SPDX header and an out-of-band entry (`REUSE.toml`/`.reuse/dep5`) disagree on the license, which is authoritative for detection? → A: It depends on the annotation's REUSE 3.3 **`precedence`** field. `closest` (the spec **default**) makes file-level info — the in-file header or its `.license` sidecar — authoritative, with the annotation a fallback; `override` makes the annotation authoritative and emits a non-failing `source_override` diagnostic; `aggregate` treats both as satisfying. `.reuse/dep5` has no `precedence` field and, per REUSE 3.3, is *aggregated* with file-level information. The declarative config (`licet.toml`) remains the source of truth for *intent*; out-of-band is read for interop/detection only, never the authoring surface. (Note: the maintainer considers `REUSE.toml` poorly suited to declarative authoring.) - Q: How are non-UTF-8 files (UTF-16, Latin-1, etc.), where byte-offset insertion could corrupt content, handled? → A: Detect and **skip** them without writing, classify them as a gate failure (`Unreadable`), and warn. Transcoding is deferred beyond v1. - Q: Can the file-selection flags (`--staged`, `--changed`, `--files`) be combined? → A: No — they are **mutually exclusive**; combining them is a usage error (exit 2). - Q: What protects source files during destructive `apply`? → A: `apply` **refuses to run on a dirty working tree** unless `--allow-dirty` is passed, and every file write is **atomic** (write-temp-then-rename) so an interruption cannot leave a partially written source file. @@ -216,5 +216,5 @@ The tool produces output that conforms to the REUSE specification (SPDX identifi - **File selection defaults to tracked files**: The repository's version-controlled file set defines coverage by default; untracked/ignored files are not enforced unless configured. - **"Fast" means non-blocking in the dev loop**: The concrete bar is "unnoticeable in a commit hook" for changed-file runs and a full scan of a ~10,000-file repository in under 1 second on repeat runs, comfortably faster than the current REUSE tool (see SC-006). - **Existing comment-style coverage is retained**: The tool starts from at least the set of file types the current REUSE tool understands and adds user-defined associations on top, rather than reimplementing fewer. -- **`REUSE.toml`/`dep5` are interop surfaces, not authoring surfaces**: out-of-band REUSE files are read for detection and compatibility (the annotation's REUSE 3.3 `precedence` decides detection when it disagrees with an in-file header — `closest` by default), but the maintainer never hand-authors licensing intent there; `license.toml` is the single declarative authoring surface. This reflects the maintainer's view that `REUSE.toml` is not designed for declarative intent. +- **`REUSE.toml`/`dep5` are interop surfaces, not authoring surfaces**: out-of-band REUSE files are read for detection and compatibility (the annotation's REUSE 3.3 `precedence` decides detection when it disagrees with an in-file header — `closest` by default), but the maintainer never hand-authors licensing intent there; `licet.toml` is the single declarative authoring surface. This reflects the maintainer's view that `REUSE.toml` is not designed for declarative intent. - **Single-binary distribution embeds a versioned SPDX corpus**: the embedded SPDX license list is a point-in-time snapshot; its version is surfaced (FR-028) and refreshing it is a rebuild/release concern, not a runtime fetch. diff --git a/src/cli/add_license.rs b/src/cli/add_license.rs index 397e93b..514648e 100644 --- a/src/cli/add_license.rs +++ b/src/cli/add_license.rs @@ -5,7 +5,7 @@ //! the operation is a copy from the bundle, never a network fetch — unless the caller //! passes `--allow-network`, which permits downloading valid-but-unbundled standard //! texts with the system `curl` binary into owned temporary storage. It writes **only** -//! under `LICENSES/` — it never modifies a source file or `license.toml`, and therefore +//! under `LICENSES/` — it never modifies a source file or `licet.toml`, and therefore //! does not require a clean working tree. //! //! Every requested identifier is validated before any directory is created or any @@ -16,7 +16,7 @@ use std::collections::BTreeSet; use super::{AddLicenseArgs, Format}; -use crate::config::LicensingConfiguration; +use crate::config::{CONFIG_FILENAME, LicensingConfiguration}; use crate::engine::Engine; use crate::error::{ExitCode, LicetError, Result}; use crate::reuse::inventory::{self, LicenseTextInventory, ValidatedId, validate_materialize_id}; @@ -49,7 +49,7 @@ pub fn run(args: AddLicenseArgs) -> Result { let config_arg = match &args.config { Some(p) if p.is_absolute() => p.clone(), Some(p) => cwd.join(p), - None => root.join("license.toml"), + None => root.join(CONFIG_FILENAME), }; let prep = prepare( &cwd, diff --git a/src/cli/init.rs b/src/cli/init.rs index a40826b..0c3f434 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -1,6 +1,6 @@ //! `init` — derive a config from current repository state (FR-018; US5). //! -//! Inspects existing headers and `REUSE.toml`/`.reuse/dep5`, then emits a `license.toml` +//! Inspects existing headers and `REUSE.toml`/`.reuse/dep5`, then emits a `licet.toml` //! whose projection reproduces current licensing. Preservation outranks brevity: //! every observed file first becomes an exact-path rule, and rules compress to an //! extension group only when every observed path they would match carries the same @@ -13,7 +13,9 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use super::{Format, InitArgs}; -use crate::config::LicensingConfiguration; +use crate::config::{ + CONFIG_FILENAME, LicensingConfiguration, has_legacy_only_config, legacy_config_error, +}; use crate::detect; use crate::error::{ExitCode, LicetError, Result}; use crate::report::{Counts, Diagnostic, Report, Summary, WriteEntry}; @@ -32,20 +34,25 @@ pub fn run(args: InitArgs) -> Result { let cwd = std::env::current_dir()?; let (root, _) = discover_root(&cwd)?; - // Destination: `--output`, else explicit `--config`, else `/license.toml`. + // Destination: `--output`, else explicit `--config`, else `/licet.toml`. // Explicit relative paths resolve from the invocation cwd. let explicit = args.output.clone().or(args.config.clone()); let abs_out = match &explicit { Some(p) if p.is_absolute() => p.clone(), Some(p) => cwd.join(p), - None => root.join("license.toml"), + None => root.join(CONFIG_FILENAME), }; + // Never write a competing default next to a legacy config: the legacy + // file would be silently ignored from then on. Rename first. + if explicit.is_none() && has_legacy_only_config(&root) { + return Err(legacy_config_error(&root)); + } // Init observes working-tree state; declaration excludes do not apply to // observation (there is no config yet to declare them). let prep = walk::prepare( &cwd, - Path::new("license.toml"), + Path::new(CONFIG_FILENAME), &Selection::FullTree, Purpose::Lint, true, @@ -424,7 +431,7 @@ fn display_path(root: &Path, abs: &Path) -> String { } } -/// Serializable shape of the generated `license.toml`. Emitted via the `toml` crate so any +/// Serializable shape of the generated `licet.toml`. Emitted via the `toml` crate so any /// license id, extension, or path is correctly quoted/escaped — hand-rolled `"{d}"` /// interpolation previously produced invalid TOML for values containing quotes, /// backslashes, or newlines. diff --git a/src/cli/lint.rs b/src/cli/lint.rs index ed44b37..8656008 100644 --- a/src/cli/lint.rs +++ b/src/cli/lint.rs @@ -4,14 +4,14 @@ //! independently of whether any declaration rule exists: each covered file //! needs a license expression and a copyright notice, and every referenced //! license text must exist under `LICENSES/`. It never parses auto-discovered -//! policy configuration — a `license.toml` in the tree is simply another +//! policy configuration — a `licet.toml` in the tree is simply another //! covered file whose own licensing metadata is checked. use std::collections::{HashMap, HashSet}; use std::path::Path; use super::{Format, LintArgs}; -use crate::config::LicensingConfiguration; +use crate::config::{CONFIG_FILENAME, LicensingConfiguration}; use crate::domain::DriftClass; use crate::engine::Engine; use crate::error::{ExitCode, LicetError, Result}; @@ -52,7 +52,7 @@ pub fn run(args: LintArgs) -> Result { // is deliberately never parsed: it is just another covered file. let prep = prepare( &cwd, - Path::new("license.toml"), + Path::new(CONFIG_FILENAME), &Selection::FullTree, Purpose::Lint, true, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 21f1928..c3e934f 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -11,6 +11,7 @@ use std::path::{Path, PathBuf}; use clap::{Args, CommandFactory, Parser, Subcommand}; use clap_complete::Shell; +use crate::config::CONFIG_FILENAME; use crate::error::{ExitCode, LicetError, Result}; use crate::spdx; use crate::walk::Selection; @@ -73,7 +74,7 @@ impl From for crate::domain::NonAnnotatableStrategy { /// Flags shared by `check` and `apply` (selection, config, format, cache). #[derive(Debug, Args)] pub struct CommonArgs { - /// Path to the declarative config (default: `/license.toml` from + /// Path to the declarative config (default: `/licet.toml` from /// the discovered root, so subdirectories work; an explicit relative /// path resolves from the invocation cwd). #[arg(long, global = true)] @@ -117,13 +118,13 @@ pub struct CommonArgs { impl CommonArgs { /// Resolve the config argument against the discovered root: an explicit /// path stays invocation-cwd-relative (absolute passes through); an - /// omitted config defaults to `/license.toml` so every command + /// omitted config defaults to `/licet.toml` so every command /// works from a subdirectory (FR-001). pub fn config_arg(&self, cwd: &Path, root: &Path) -> PathBuf { match &self.config { Some(p) if p.is_absolute() => p.clone(), Some(p) => cwd.join(p), - None => root.join("license.toml"), + None => root.join(CONFIG_FILENAME), } } @@ -212,7 +213,7 @@ pub struct ApplyArgs { pub struct InitArgs { /// Read as the destination when `--output` is absent (init writes a /// config, so `--config` names where it goes, not where policy comes - /// from). Defaults to `/license.toml`. + /// from). Defaults to `/licet.toml`. #[arg(long)] pub config: Option, /// Compatibility alias: init always inspects existing REUSE state @@ -259,7 +260,7 @@ pub struct AddLicenseArgs { #[arg(long)] pub allow_network: bool, /// Path to the declarative config (only read by --all to discover referenced ids). - /// Defaults to `/license.toml`; an explicit relative path resolves + /// Defaults to `/licet.toml`; an explicit relative path resolves /// from the invocation cwd. #[arg(long)] pub config: Option, diff --git a/src/config/mod.rs b/src/config/mod.rs index 5dad889..792e520 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,7 +1,11 @@ //! Declarative configuration model + loading/validation (FR-001, FR-010). //! -//! `license.toml` is the only authoring surface for licensing intent +//! `licet.toml` is the only authoring surface for licensing intent //! (contracts/config-schema.md). Validation errors map to exit code 2. +//! +//! The default filename is `licet.toml` (not `license.toml`): names containing +//! `license` are claimed by license-detection heuristics (GitHub licensee, +//! REUSE tooling) that would misread a declarative config as a license text. pub mod schema; @@ -16,6 +20,29 @@ use crate::spdx; use schema::{RawConfig, RawIntent, RawRule, RawStyle}; use smol_str::SmolStr; +/// Default declarative-config filename at the repository root (FR-001). +pub const CONFIG_FILENAME: &str = "licet.toml"; +/// Pre-rename filename: never defaulted to, but recognized to point users at +/// the rename when it is the only config present. +pub const LEGACY_CONFIG_FILENAME: &str = "license.toml"; + +/// Error when the default config is absent but the legacy filename exists. +/// An explicit `--config` path bypasses this (it names exactly what to read). +pub fn legacy_config_error(root: &Path) -> LicetError { + LicetError::Config(format!( + "found legacy `{}` in {}; the config file was renamed to `{}` — \ + rename it (or pass its path explicitly with `--config`)", + LEGACY_CONFIG_FILENAME, + root.display(), + CONFIG_FILENAME + )) +} + +/// True when the legacy config exists and the default one does not. +pub fn has_legacy_only_config(root: &Path) -> bool { + !root.join(CONFIG_FILENAME).exists() && root.join(LEGACY_CONFIG_FILENAME).exists() +} + /// A reference to a comment style: a built-in name or an inline definition. #[derive(Debug, Clone, PartialEq, Eq)] pub enum CommentStyleRef { @@ -58,7 +85,7 @@ pub struct LicensingConfiguration { } impl LicensingConfiguration { - /// Load and validate config from a path (default `./license.toml`). + /// Load and validate config from a path (default `./licet.toml`). pub fn load(path: &Path) -> Result { let text = std::fs::read_to_string(path).map_err(|e| { LicetError::Config(format!("cannot read config `{}`: {e}", path.display())) @@ -68,8 +95,8 @@ impl LicensingConfiguration { /// Parse and validate config from a TOML string. pub fn from_toml(text: &str) -> Result { - let raw: RawConfig = toml::from_str(text) - .map_err(|e| LicetError::Config(format!("invalid license.toml: {e}")))?; + let raw: RawConfig = + toml::from_str(text).map_err(|e| LicetError::Config(format!("invalid config: {e}")))?; Self::from_raw(raw) } diff --git a/src/config/schema.rs b/src/config/schema.rs index 5cbda22..fe162be 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -1,9 +1,9 @@ -//! Raw `serde` deserialization shapes for `license.toml` (contracts/config-schema.md). +//! Raw `serde` deserialization shapes for `licet.toml` (contracts/config-schema.md). //! Validated into the domain [`super::LicensingConfiguration`] by `super::mod`. use serde::Deserialize; -/// Top-level `license.toml` document. +/// Top-level `licet.toml` document. #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] pub struct RawConfig { diff --git a/src/walk/mod.rs b/src/walk/mod.rs index da3791b..33e6718 100644 --- a/src/walk/mod.rs +++ b/src/walk/mod.rs @@ -17,6 +17,7 @@ use std::path::{Component, Path, PathBuf}; use ignore::WalkBuilder; use ignore::overrides::OverrideBuilder; +use crate::config::{CONFIG_FILENAME, has_legacy_only_config, legacy_config_error}; use crate::error::{LicetError, Result}; pub use git::{GitRepo, Snapshot}; @@ -264,7 +265,7 @@ pub fn prepare( /// root; `None` when it points outside (worktree reads only — an index snapshot /// cannot contain it). fn resolve_config_rel(cwd: &Path, root: &Path, config_arg: &Path) -> Option { - // Omitted configs arrive as absolute `/license.toml` (see + // Omitted configs arrive as absolute `/licet.toml` (see // `CommonArgs::config_arg`); every explicit relative path stays // invocation-cwd-relative. let abs = if config_arg.is_absolute() { @@ -295,7 +296,7 @@ fn read_config_text( // lenient read (missing/unparseable content falls back later). let path = match config_rel { Some(rel) => root.join(rel), - None => PathBuf::from("license.toml"), + None => PathBuf::from(CONFIG_FILENAME), }; Ok(std::fs::read_to_string(path).unwrap_or_default()) } @@ -324,10 +325,16 @@ fn read_snapshot_config( )) }), None if allow_missing_config => Ok(String::new()), - None => Err(LicetError::Config(format!( - "cannot read config `{}`: not present in the evaluated snapshot", - root.join(rel).display() - ))), + None => Err( + if rel == Path::new(CONFIG_FILENAME) && has_legacy_only_config(root) { + legacy_config_error(root) + } else { + LicetError::Config(format!( + "cannot read config `{}`: not present in the evaluated snapshot", + root.join(rel).display() + )) + }, + ), } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 07d1d9e..b9e15f8 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -66,9 +66,9 @@ impl Fixture { self } - /// Write `license.toml`. + /// Write `licet.toml`. pub fn config(&self, toml: &str) -> &Self { - self.write("license.toml", toml) + self.write("licet.toml", toml) } /// Write bundled `LICENSES/.txt` texts (policy `check` requires the diff --git a/tests/perf.rs b/tests/perf.rs index 0e93eb3..197a08c 100644 --- a/tests/perf.rs +++ b/tests/perf.rs @@ -11,7 +11,7 @@ use std::time::Instant; #[test] fn scans_a_few_thousand_files_quickly() { let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n"); + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n"); let n = 3000; for i in 0..n { let dir = i / 100; @@ -44,7 +44,7 @@ fn scans_a_few_thousand_files_quickly() { fn long_licenseref_scales_without_quadratic_blowup() { fn check_seconds_for_id_len(len: usize) -> f64 { let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write( "big.rs", &format!( diff --git a/tests/report_contract.rs b/tests/report_contract.rs index 134d4a2..f08e05b 100644 --- a/tests/report_contract.rs +++ b/tests/report_contract.rs @@ -49,7 +49,7 @@ fn report_schema_is_valid_v2() { #[test] fn every_command_json_stdout_is_one_document() { let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") .write("b.py", "x = 1\n") .texts(&["MIT"]) diff --git a/tests/snapshots/us1_snapshots__drift_report_human.snap b/tests/snapshots/us1_snapshots__drift_report_human.snap index 198dbb9..9f3abaf 100644 --- a/tests/snapshots/us1_snapshots__drift_report_human.snap +++ b/tests/snapshots/us1_snapshots__drift_report_human.snap @@ -5,7 +5,7 @@ expression: stdout wrong_license b_wrong.py (declared `MIT` vs actual `GPL-3.0-only`) missing_header c_missing.py (declared `MIT`, no header found) wrong_license d_wrong.rs (declared `Apache-2.0` vs actual `MIT`) [ext=rs] -missing_header license.toml (declared `MIT`, no header found) +missing_header licet.toml (declared `MIT`, no header found) Summary: 1 compliant, 2 wrong-license, 2 missing, 0 uncovered, 3 excluded, 0 unreadable Result: FAIL diff --git a/tests/snapshots/us1_snapshots__uncovered_report_human.snap b/tests/snapshots/us1_snapshots__uncovered_report_human.snap index 5b2b2bd..a86ff49 100644 --- a/tests/snapshots/us1_snapshots__uncovered_report_human.snap +++ b/tests/snapshots/us1_snapshots__uncovered_report_human.snap @@ -2,7 +2,7 @@ source: tests/us1_snapshots.rs expression: stdout --- -uncovered license.toml +uncovered licet.toml uncovered orphan.py Summary: 0 compliant, 0 wrong-license, 0 missing, 2 uncovered, 0 excluded, 0 unreadable diff --git a/tests/us1_snapshots.rs b/tests/us1_snapshots.rs index 7d2e254..eeb17df 100644 --- a/tests/us1_snapshots.rs +++ b/tests/us1_snapshots.rs @@ -48,7 +48,7 @@ fn uncovered_report_human() { #[test] fn compliant_report_human() { let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("a.py", "# SPDX-License-Identifier: MIT\nx=1\n") .texts(&["MIT"]) .commit("init"); diff --git a/tests/us2_apply.rs b/tests/us2_apply.rs index 9042fb1..7a87a2c 100644 --- a/tests/us2_apply.rs +++ b/tests/us2_apply.rs @@ -211,7 +211,7 @@ fn program_text_tag_is_unfixable_and_untouched() { // The fixture's own config file carries a header so the only failure // is the unfixable one (exit 1, not partial). .write( - "license.toml", + "licet.toml", "# SPDX-License-Identifier: MIT\n[default]\nlicense=\"MIT\"\n", ) .commit("init"); @@ -550,7 +550,7 @@ fn additive_success_with_unfixable_remainder_is_exit_1_not_partial() { "FOO=SPDX-License-Identifier: Apache-2.0\nfn b(){}\n", ) .write( - "license.toml", + "licet.toml", "# SPDX-License-Identifier: MIT\n[default]\nlicense=\"MIT\"\n", ) .commit("init"); diff --git a/tests/us3_detect.rs b/tests/us3_detect.rs index ac253c9..d792ad3 100644 --- a/tests/us3_detect.rs +++ b/tests/us3_detect.rs @@ -25,7 +25,7 @@ fn compound_parenthesized_both_orders_agree() { "// SPDX-License-Identifier: Apache-2.0 OR (MIT)\n", ] { let f = Fixture::new(); - f.config("[default]\nlicense=\"Apache-2.0 OR MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"Apache-2.0 OR MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("a.rs", header) .texts(&["MIT", "Apache-2.0"]) .commit("init"); @@ -58,7 +58,7 @@ fn tab_separated_operators_pass_gate() { // strict — lowercase ids are diagnosed as invalid — while comparison // tolerates id case; see the spdx unit tests.) let f = Fixture::new(); - f.config("[default]\nlicense=\"Apache-2.0 OR MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"Apache-2.0 OR MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write( "a.rs", "// SPDX-FileCopyrightText: 2026 Acme\n// SPDX-License-Identifier: MIT\tor\tApache-2.0\n", @@ -89,7 +89,7 @@ fn compound_detection_keeps_full_expression() { fn additive_apply_flags_contradiction() { // `apply --additive` that leaves two contradictory licenses warns explicitly. let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("a.rs", "// SPDX-License-Identifier: Apache-2.0\n") .commit("init"); let out = f @@ -149,7 +149,7 @@ fn invalid_license_value_diagnosed_with_line_and_copyrights_kept() { #[test] fn invalid_value_warning_reaches_gate_output() { let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write( "a.rs", "// SPDX-FileCopyrightText: 2026 Acme\n// SPDX-License-Identifier: Bogus-1.0\n", @@ -179,7 +179,7 @@ fn invalid_value_warning_reaches_gate_output() { fn orphan_sidecar_content_has_no_effect() { // A valid orphan sidecar is diagnosed AND its text leaks into no file state. let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("ok.rs", "// SPDX-License-Identifier: MIT\n") .write("ghost.rs.license", "SPDX-License-Identifier: Apache-2.0\n") .commit("init"); diff --git a/tests/us4_enforce.rs b/tests/us4_enforce.rs index 8a4de56..8a6c9d8 100644 --- a/tests/us4_enforce.rs +++ b/tests/us4_enforce.rs @@ -29,7 +29,7 @@ fn staged_subset_blocks_on_drift_and_names_file() { #[test] fn compliant_staged_set_passes() { let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .commit("baseline"); f.write("ok.rs", "// SPDX-License-Identifier: MIT\nfn o(){}\n") .texts(&["MIT"]) @@ -46,7 +46,7 @@ fn compliant_staged_set_passes() { #[test] fn staged_check_reads_index_bytes() { let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("f.rs", "// SPDX-License-Identifier: MIT\n") .commit("base"); f.write("f.rs", "// SPDX-License-Identifier: Apache-2.0\n") @@ -80,7 +80,7 @@ fn index_tree(f: &Fixture) -> String { fn staged_check_reverse_content_case() { // Index says MIT (compliant), worktree says Apache: the gate sees the index. let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("f.rs", "// SPDX-License-Identifier: Apache-2.0\n") .texts(&["MIT", "Apache-2.0"]) .commit("base"); @@ -135,7 +135,7 @@ fn non_utf8_file_is_unreadable_and_fails_gate() { fn file_selection_normalizes_dot_segments() { // `src/x.rs`, `./src/x.rs`, and `src/../src/x.rs` name one file. let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("src/x.rs", "fn x(){}\n") .commit("init"); for spelling in ["src/x.rs", "./src/x.rs", "src/../src/x.rs"] { @@ -155,7 +155,7 @@ fn file_selection_normalizes_dot_segments() { #[test] fn file_selection_from_subdirectory() { let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("a.rs", "fn a(){}\n") .write("sub/b.rs", "fn b(){}\n") .commit("init"); @@ -201,7 +201,7 @@ fn file_selection_rejects_outside_root() { fn file_selection_skips_symlinks_regardless_of_order() { use std::os::unix::fs::symlink; let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("real.rs", "// SPDX-License-Identifier: MIT\n") .texts(&["MIT"]) .commit("init"); @@ -247,7 +247,7 @@ fn file_selection_skips_symlinks_regardless_of_order() { fn non_utf8_filename_survives_selection() { use std::os::unix::ffi::OsStringExt; let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .commit("init"); // Latin-1 `caf\xe9.rs`: not valid UTF-8, but a real tracked file. let name = std::ffi::OsString::from_vec(b"caf\xe9.rs".to_vec()); @@ -274,7 +274,7 @@ fn non_utf8_filename_survives_selection() { fn literal_backslash_is_not_a_separator() { // A Unix filename containing a literal backslash must not be split. let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .commit("init"); std::fs::write(f.path().join("a\\b.rs"), b"fn x() {}\n").unwrap(); f.git(&["add", "-A"]); @@ -296,7 +296,7 @@ fn reuse_ignore_matrix() { // A root file literally named `LICENSES` is covered (not ignored); it gets // its own fixture because `LICENSES/` is a directory everywhere else here. let g = Fixture::new(); - g.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + g.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("LICENSES", "I am an ordinary covered file\n") .commit("init"); let out = g @@ -316,7 +316,7 @@ fn reuse_ignore_matrix() { ); let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("LICENSES/MIT.txt", mit) .write("COPYING", "x\n") .write("LICENSE-MIT", "x\n") @@ -378,7 +378,7 @@ fn reuse_ignore_matrix() { #[test] fn lint_ignores_declaration_exclusions_but_sees_untracked() { let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\", \"hide.rs\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\", \"hide.rs\"]\n") .write("ok.rs", "// SPDX-License-Identifier: MIT\n") .write("hide.rs", "fn h(){}\n") .texts(&["MIT"]) @@ -405,7 +405,7 @@ fn lint_ignores_declaration_exclusions_but_sees_untracked() { #[test] fn tracked_gitignored_file_stays_covered() { let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("a.rs", "fn a(){}\n") .commit("init"); // A later ignore rule cannot drop a tracked file from policy coverage. @@ -433,7 +433,7 @@ fn linked_worktree_dot_git_file_not_scanned() { // In a linked worktree `.git` is a control FILE and enumeration comes from // `ls-files`, so internals can never leak into coverage. let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("ok.rs", "// SPDX-License-Identifier: MIT\n") .texts(&["MIT"]) .commit("init"); @@ -470,7 +470,7 @@ fn linked_worktree_dot_git_file_not_scanned() { #[test] fn git_internals_never_evaluated() { let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("ok.rs", "// SPDX-License-Identifier: MIT\n") .texts(&["MIT"]) .commit("init"); @@ -517,7 +517,7 @@ fn selection_flags_are_mutually_exclusive() { fn staged_check_on_unborn_head_evaluates_index() { // No commit exists: the staged set is the full index. let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("f.rs", "// SPDX-License-Identifier: MIT\n") .texts(&["MIT"]) .stage_all(); @@ -541,7 +541,7 @@ fn staged_check_on_unborn_head_evaluates_index() { #[test] fn staged_rename_evaluates_new_path() { let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("a.rs", "// SPDX-License-Identifier: MIT\n") .texts(&["MIT"]) .commit("base"); @@ -565,7 +565,7 @@ fn staged_rename_evaluates_new_path() { #[test] fn unresolved_merge_index_is_usage_error() { let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("m.rs", "// base\n") .commit("base"); f.git(&["checkout", "-qb", "side"]); @@ -594,7 +594,7 @@ fn unresolved_merge_index_is_usage_error() { #[test] fn staged_sidecar_supersedes_worktree_sidecar() { let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") .texts(&["MIT", "Apache-2.0"]) .commit("base"); @@ -630,7 +630,7 @@ fn staged_sidecar_supersedes_worktree_sidecar() { #[test] fn staged_metadata_change_expands_to_full_set() { let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("a.rs", "// SPDX-License-Identifier: MIT\n") // Apache text up front: the staged override is about to reference it. .texts(&["MIT", "Apache-2.0"]) @@ -673,7 +673,7 @@ fn staged_metadata_change_expands_to_full_set() { fn staged_license_text_deletion_expands_selection() { use licet::spdx::bundled_text; let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("a.rs", "// SPDX-License-Identifier: MIT\n") .write("LICENSES/MIT.txt", bundled_text("MIT").unwrap()) .commit("base"); @@ -723,7 +723,7 @@ fn staged_custom_config_must_be_in_index() { .unwrap(); assert_eq!(out.status.code(), Some(2)); // Once staged, the check reads the staged config (Apache default makes the - // MIT file drift — proving the index bytes were used, not license.toml). + // MIT file drift — proving the index bytes were used, not licet.toml). f.git(&["add", "custom.toml"]); let out = f .licet() @@ -755,7 +755,7 @@ fn staged_custom_config_must_be_in_index() { #[test] fn apply_staged_edits_worktree_leaves_index() { let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("f.rs", "// SPDX-License-Identifier: MIT\n") .commit("base"); f.write("f.rs", "// SPDX-License-Identifier: Apache-2.0\n") @@ -867,7 +867,7 @@ fn explain_nonexistent_path_is_usage_error() { #[test] fn default_config_resolves_from_root_in_subdirectory() { let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") .write("sub/b.rs", "// SPDX-License-Identifier: MIT\nfn b(){}\n") .texts(&["MIT"]) @@ -896,22 +896,81 @@ fn explicit_missing_config_is_usage_error() { .unwrap(); assert_eq!(out.status.code(), Some(2)); // An explicit relative path stays cwd-relative: from a subdir without - // its own license.toml this names a missing file, unlike the omitted + // its own licet.toml this names a missing file, unlike the omitted // default which resolves from the root. let out = f .licet_in("sub") - .args(["check", "--config", "license.toml"]) + .args(["check", "--config", "licet.toml"]) .output() .unwrap(); assert_eq!(out.status.code(), Some(2)); } +#[test] +fn legacy_only_config_errors_with_rename_hint() { + // Default load reads `licet.toml`; a lone `license.toml` is the pre-rename + // filename and must point at the rename, not fail as "missing config". + let f = Fixture::new(); + f.write("license.toml", "[default]\nlicense=\"MIT\"\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .commit("init"); + let out = f.licet().arg("check").output().unwrap(); + assert_eq!(out.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("license.toml") && stderr.contains("licet.toml"), + "rename hint names both files: {stderr}" + ); +} + +#[test] +fn explicit_legacy_config_path_still_reads() { + // Escape hatch: an explicitly named path is read as-is, whatever its name. + let f = Fixture::new(); + f.write("license.toml", "[default]\nlicense=\"MIT\"\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .texts(&["MIT"]) + .commit("init"); + let out = f + .licet() + .args(["check", "--config", "license.toml", "--files", "a.rs"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "explicit legacy path works: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn init_refuses_default_when_legacy_config_present() { + // `init` must not write a competing `licet.toml` next to a legacy file + // that would then be silently ignored. + let f = Fixture::new(); + f.write("license.toml", "[default]\nlicense=\"MIT\"\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .commit("init"); + let out = f.licet().arg("init").output().unwrap(); + assert_eq!(out.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("licet.toml"), + "init points at the rename: {stderr}" + ); + assert!( + !f.path().join("licet.toml").exists(), + "no competing default written" + ); +} + #[test] fn broken_stdout_pipe_exits_quietly() { use std::io::Read; use std::process::Stdio; let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .texts(&["MIT"]); // Enough files that the JSON report exceeds the pipe buffer, so the // writer deterministically hits EPIPE once the reader goes away. diff --git a/tests/us5_add_license.rs b/tests/us5_add_license.rs index 7d5ef2c..185903d 100644 --- a/tests/us5_add_license.rs +++ b/tests/us5_add_license.rs @@ -398,7 +398,7 @@ fn writes_only_under_licenses_and_does_not_require_clean_tree() { // Make the tree dirty — unlike `apply`, `add-license` must still proceed. f.write("a.rs", "fn a(){ /* edited */ }\n"); let before = f.read("a.rs"); - let before_cfg = f.read("license.toml"); + let before_cfg = f.read("licet.toml"); let out = f.licet().args(["add", "MIT"]).output().unwrap(); assert_eq!( @@ -409,11 +409,7 @@ fn writes_only_under_licenses_and_does_not_require_clean_tree() { ); // Source file and config are untouched; only LICENSES/ changed. assert_eq!(f.read("a.rs"), before, "source file must be untouched"); - assert_eq!( - f.read("license.toml"), - before_cfg, - "config must be untouched" - ); + assert_eq!(f.read("licet.toml"), before_cfg, "config must be untouched"); assert!(f.path().join("LICENSES/MIT.txt").exists()); } diff --git a/tests/us5_reuse.rs b/tests/us5_reuse.rs index f9b3364..350e572 100644 --- a/tests/us5_reuse.rs +++ b/tests/us5_reuse.rs @@ -25,7 +25,7 @@ fn init_generates_config_reproducing_current_licensing() { "{}", String::from_utf8_lossy(&out.stderr) ); - let cfg = f.read("license.toml"); + let cfg = f.read("licet.toml"); // Most common license (MIT) becomes the default; rust (Apache) becomes an ext rule. assert!(cfg.contains("[default]") && cfg.contains("MIT"), "{cfg}"); assert!( @@ -151,7 +151,7 @@ fn matrix_fixture(root_doc: Option<&str>, sub_doc: Option<&str>, file: &str) -> fn check_file(f: &Fixture, intent: &str) -> serde_json::Value { std::fs::write( - f.path().join("license.toml"), + f.path().join("licet.toml"), format!("[default]\nlicense=\"{intent}\"\n"), ) .unwrap(); @@ -344,7 +344,7 @@ fn precedence_matrix_sidecar_beats_source() { ) .commit("init"); std::fs::write( - f.path().join("license.toml"), + f.path().join("licet.toml"), "[default]\nlicense=\"Apache-2.0\"\n", ) .unwrap(); @@ -408,7 +408,7 @@ fn staged_nested_reuse_toml_reads_index_bytes() { // Nested metadata participates in the staged snapshot: prefetched index // blobs, never the working copy. let f = Fixture::new(); - f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"license.toml\"]\n") + f.config("[default]\nlicense=\"MIT\"\n[exclude]\npaths=[\"licet.toml\"]\n") .write("sub/f.rs", "// SPDX-License-Identifier: MIT\n") .write( "sub/REUSE.toml", @@ -666,7 +666,7 @@ fn warning_kinds(v: &serde_json::Value) -> Vec { #[test] fn lint_succeeds_without_any_config_when_metadata_complete() { - // REUSE validation is declaration-independent: no license.toml at all. + // REUSE validation is declaration-independent: no licet.toml at all. let f = Fixture::new(); f.write( "a.rs", @@ -827,9 +827,9 @@ fn lint_explicit_config_missing_or_malformed_is_usage_error() { "LICENSES/MIT.txt", licet::spdx::bundled_text("MIT").unwrap(), ); - for cfg in ["does/not-exist.toml", "license.toml"] { - if cfg == "license.toml" { - f.write("license.toml", "[default\nbroken\n"); + for cfg in ["does/not-exist.toml", "licet.toml"] { + if cfg == "licet.toml" { + f.write("licet.toml", "[default\nbroken\n"); } let out = f.licet().args(["lint", "--config", cfg]).output().unwrap(); assert_eq!(out.status.code(), Some(2), "explicit config {cfg}"); @@ -846,7 +846,7 @@ fn lint_explicit_valid_config_is_accepted_but_ignored() { // The declared Apache intent is irrelevant: actual MIT metadata validates. let out = f .licet() - .args(["lint", "--format", "json", "--config", "license.toml"]) + .args(["lint", "--format", "json", "--config", "licet.toml"]) .output() .unwrap(); assert_eq!(out.status.code(), Some(0)); @@ -856,11 +856,11 @@ fn lint_explicit_valid_config_is_accepted_but_ignored() { #[test] fn lint_annotated_but_malformed_policy_toml_is_covered_file() { - // An auto-discovered license.toml that fails to parse is not a lint + // An auto-discovered licet.toml that fails to parse is not a lint // blocker: with its own SPDX tags it validates like any covered file. let f = Fixture::new(); f.write( - "license.toml", + "licet.toml", "# SPDX-License-Identifier: MIT\n# SPDX-FileCopyrightText: 2026 Acme\n[default\nbroken\n", ) .write( @@ -940,7 +940,7 @@ fn init_round_trip_preserves_all_observed_licensing() { "init succeeds: {}", String::from_utf8_lossy(&out.stderr) ); - std::fs::copy(f.path().join("gen.toml"), f.path().join("license.toml")).unwrap(); + std::fs::copy(f.path().join("gen.toml"), f.path().join("licet.toml")).unwrap(); let out = f .licet() @@ -1003,7 +1003,7 @@ fn init_refuses_overwrite_without_force() { let out = f.licet().arg("init").output().unwrap(); assert_eq!(out.status.code(), Some(0)); - let first = f.read("license.toml"); + let first = f.read("licet.toml"); let out = f.licet().arg("init").output().unwrap(); assert_eq!( @@ -1011,7 +1011,7 @@ fn init_refuses_overwrite_without_force() { Some(2), "second init refuses without --force" ); - assert_eq!(f.read("license.toml"), first, "existing config untouched"); + assert_eq!(f.read("licet.toml"), first, "existing config untouched"); assert_eq!( f.read("a.rs"), "// SPDX-License-Identifier: MIT\nfn a(){}\n", @@ -1026,7 +1026,7 @@ fn init_refuses_overwrite_without_force() { String::from_utf8_lossy(&out.stderr) ); assert_eq!( - f.read("license.toml"), + f.read("licet.toml"), first, "identical intent → identical bytes" ); @@ -1110,7 +1110,7 @@ fn init_handles_quote_paths() { "quoted path generates: {}", String::from_utf8_lossy(&out.stderr) ); - std::fs::copy(f.path().join("gen.toml"), f.path().join("license.toml")).unwrap(); + std::fs::copy(f.path().join("gen.toml"), f.path().join("licet.toml")).unwrap(); let out = f .licet() .args(["check", "--format", "json", "--files", "we\"ird.rs"]) From b0ae2290246ca618d0cdf89553f2030720addf04 Mon Sep 17 00:00:00 2001 From: Adam Poulemanos Date: Fri, 11 Sep 2026 09:19:23 -0400 Subject: [PATCH 04/12] fix: resolve git/curl without consulting the working directory Every licet command runs with the evaluated repository as its working directory, and OS executable search can consult the CWD (notably on Windows, where it precedes PATH entries). A checkout containing a planted git.exe/curl could therefore supply the helper binary. New crate::tool::resolve scans PATH explicitly, ignoring empty entries (CWD by convention) and relative entries (CWD-relative), requires a regular executable file, and probes .exe on Windows. All git (3 sites) and curl (fetch path, already --disable/proto-pinned) spawns go through it; unresolvable tools fail exit 2 naming the PATH lookup. Covered by 5 hermetic resolver unit tests plus an integration test running check with an empty PATH. --- src/lib.rs | 1 + src/reuse/inventory.rs | 11 ++- src/tool.rs | 167 +++++++++++++++++++++++++++++++++++++++++ src/walk/git.rs | 13 +++- tests/us4_enforce.rs | 14 ++++ 5 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 src/tool.rs diff --git a/src/lib.rs b/src/lib.rs index c02f6e0..7654547 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ pub mod report; pub mod reuse; pub mod rules; pub mod spdx; +pub mod tool; pub mod walk; pub use error::{ExitCode, LicetError, Result}; diff --git a/src/reuse/inventory.rs b/src/reuse/inventory.rs index 177ee3c..5aa0829 100644 --- a/src/reuse/inventory.rs +++ b/src/reuse/inventory.rs @@ -560,7 +560,16 @@ pub fn fetch_text_via_curl(id: &str) -> Result, FetchError> { let url = download_url(id); let tmp = tempfile::tempdir().map_err(FetchError::Launch)?; let out_path = tmp.path().join("license.txt"); - let output = std::process::Command::new("curl") + // Resolve without consulting the working directory ([`crate::tool`]): the + // download runs for the repository under evaluation, which must never + // supply the executable itself. + let curl = crate::tool::resolve("curl").map_err(|e| { + FetchError::Launch(std::io::Error::new( + std::io::ErrorKind::NotFound, + e.to_string(), + )) + })?; + let output = std::process::Command::new(curl) .arg("--disable") .arg("--fail") .arg("--silent") diff --git a/src/tool.rs b/src/tool.rs new file mode 100644 index 0000000..25775ee --- /dev/null +++ b/src/tool.rs @@ -0,0 +1,167 @@ +//! Trusted resolution of external helper binaries (`git`, `curl`) (F06, F07). +//! +//! Every licet command runs with an attacker-influenced current directory +//! (the repository under evaluation), so helpers must never be resolved +//! through OS executable-search semantics: on Windows the working directory +//! is part of the search order, letting a checkout containing a planted +//! `git.exe` win over the real tool installed later on `PATH`. Resolution +//! here is an explicit `PATH` scan that ignores empty entries (which mean +//! "the current directory" by convention) and relative entries (which +//! resolve against the current directory), returning an absolute path. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +/// A helper binary that could not be resolved to an absolute path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnresolvedTool { + /// Bare binary name that was requested (e.g. `"git"`). + pub name: String, +} + +impl std::fmt::Display for UnresolvedTool { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "helper `{}` not found via PATH lookup (empty and relative PATH \ + entries are ignored so a repository checkout can never supply \ + the binary); install it or check PATH", + self.name + ) + } +} + +impl std::error::Error for UnresolvedTool {} + +/// Resolve `name` to an absolute path via [`resolve_in`] over the live `PATH`. +pub fn resolve(name: &str) -> Result { + let path_var = std::env::var_os("PATH").unwrap_or_default(); + resolve_in(name, &path_var).ok_or_else(|| UnresolvedTool { + name: name.to_string(), + }) +} + +/// Scan one `PATH` value for `name`, skipping entries that resolve against +/// the current directory. Separated for hermetic testing (no env mutation). +fn resolve_in(name: &str, path_var: &OsStr) -> Option { + // Windows executes `name` or `name.exe`; Unix uses `name` exactly. + let mut candidates = vec![name.to_string()]; + if cfg!(windows) { + candidates.push(format!("{name}.exe")); + } + for dir in std::env::split_paths(path_var) { + // Empty entries mean CWD; relative entries resolve from the CWD. + // Both would let the evaluated checkout supply the binary. + if dir.as_os_str().is_empty() || dir.is_relative() { + continue; + } + for candidate in &candidates { + let full = dir.join(candidate); + if is_executable_file(&full) { + return Some(full); + } + } + } + None +} + +/// A regular file the OS would execute (executable bit on Unix). +fn is_executable_file(path: &Path) -> bool { + let Ok(meta) = std::fs::metadata(path) else { + return false; + }; + if !meta.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + meta.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fake `PATH` root containing `name` (executable on Unix). + fn bin_dir(name: &str) -> tempfile::TempDir { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join(name); + std::fs::write(&path, b"fake").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&path, perms).unwrap(); + } + dir + } + + fn join_path(dirs: &[&Path]) -> std::ffi::OsString { + std::env::join_paths(dirs.iter()).unwrap() + } + + #[test] + fn finds_binary_in_absolute_path_entry() { + let bin = bin_dir("git"); + let found = resolve_in("git", &join_path(&[bin.path()])).unwrap(); + assert_eq!(found, bin.path().join("git")); + } + + #[test] + fn skips_empty_and_relative_path_entries() { + let bin = bin_dir("git"); + // Empty entries mean the CWD by convention; relative entries resolve + // from the CWD. Both are skipped before any filesystem check, so a + // PATH of only untrusted entries resolves nothing even though an + // absolute entry finds the binary. + let path_var = join_path(&[Path::new(""), Path::new("rel-bin"), bin.path()]); + assert_eq!(resolve_in("git", &path_var), Some(bin.path().join("git"))); + let untrusted = join_path(&[Path::new(""), Path::new("rel-bin")]); + assert_eq!(resolve_in("git", &untrusted), None); + } + + #[test] + fn missing_binary_resolves_to_none() { + let bin = bin_dir("git"); + assert_eq!(resolve_in("curl", &join_path(&[bin.path()])), None); + } + + #[test] + fn non_executable_file_is_not_a_match() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write(dir.path().join("git"), b"not executable").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(dir.path().join("git")) + .unwrap() + .permissions(); + perms.set_mode(0o644); + std::fs::set_permissions(dir.path().join("git"), perms).unwrap(); + } + #[cfg(not(unix))] + { + // On non-Unix every regular file counts; just assert the positive + // path instead of fighting platform semantics here. + assert!(is_executable_file(&dir.path().join("git"))); + return; + } + assert_eq!(resolve_in("git", &join_path(&[dir.path()])), None); + } + + #[test] + fn unresolved_error_names_the_tool() { + let err = UnresolvedTool { + name: "git".to_string(), + }; + let msg = err.to_string(); + assert!(msg.contains("`git`") && msg.contains("PATH"), "{msg}"); + } +} diff --git a/src/walk/git.rs b/src/walk/git.rs index 7cf18b9..d80cacd 100644 --- a/src/walk/git.rs +++ b/src/walk/git.rs @@ -17,10 +17,17 @@ use std::path::{Path, PathBuf}; use crate::domain::ContentSource; use crate::error::{LicetError, Result}; +/// Resolve the system `git` binary without consulting the working directory +/// ([`crate::tool`]): every subprocess below runs with the evaluated +/// repository as its CWD, which must never supply the executable itself. +fn git_binary() -> Result { + crate::tool::resolve("git").map_err(|e| LicetError::Git(e.to_string())) +} + /// Run a checked `git` command in `root`; return raw stdout. Failures retain /// stderr context. Arguments are passed as an argv array — never shell text. pub fn git_output(root: &Path, args: &[&OsStr]) -> Result> { - let output = std::process::Command::new("git") + let output = std::process::Command::new(git_binary()?) .arg("-C") .arg(root) .args(args) @@ -66,7 +73,7 @@ pub enum RepoDisposition { /// Discover the repository disposition of `start`, distinguishing "not a /// repository" from Git being missing or failing (F13). pub fn discover_repo(start: &Path) -> Result { - let output = std::process::Command::new("git") + let output = std::process::Command::new(git_binary()?) .arg("-C") .arg(start) .args(["rev-parse", "--show-toplevel", "--absolute-git-dir"]) @@ -267,7 +274,7 @@ impl IndexSnapshot { request.extend_from_slice(oid.as_bytes()); request.push(b'\n'); } - let mut child = std::process::Command::new("git") + let mut child = std::process::Command::new(git_binary()?) .arg("-C") .arg(root) .args(["cat-file", "--batch"]) diff --git a/tests/us4_enforce.rs b/tests/us4_enforce.rs index 8a6c9d8..ad706b6 100644 --- a/tests/us4_enforce.rs +++ b/tests/us4_enforce.rs @@ -965,6 +965,20 @@ fn init_refuses_default_when_legacy_config_present() { ); } +#[test] +fn unresolvable_git_is_usage_error_naming_path() { + // Helpers resolve via PATH without the working directory (crate::tool): + // with no git on PATH the failure names the lookup, exit 2. + let f = Fixture::new(); + f.config("[default]\nlicense=\"MIT\"\n") + .write("a.rs", "// SPDX-License-Identifier: MIT\nfn a(){}\n") + .commit("init"); + let out = f.licet().env("PATH", "").arg("check").output().unwrap(); + assert_eq!(out.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("PATH"), "names PATH lookup: {stderr}"); +} + #[test] fn broken_stdout_pipe_exits_quietly() { use std::io::Read; From bacbe46d185f6151c8d4452a3f0c42b5a46835d9 Mon Sep 17 00:00:00 2001 From: Adam Poulemanos Date: Fri, 11 Sep 2026 09:19:29 -0400 Subject: [PATCH 05/12] refactor: address review complexity, split oob module, docs nits Answer the Codacy findings with behavior-preserving extractions, each covered by the existing suites: - atomic_write: extract classify_destination (destination-state classification without following symlinks) - plan_file: extract plan_missing_header and resolve_target_block (target validation stays refusal-only, never clamping) - prepare: extract the common Assembly/assemble tail shared by all five selection arms (also removes the duplicated packing) - lint: extract explicit --config validation (check_explicit_config) - reuse/oob.rs (1828 lines) -> reuse/oob/{mod,parse,lookup,write,tests}; public paths unchanged via re-exports - dep5: drop the redundant strip_suffix (every value path trims; verified by mutation) and pin CRLF-tolerance with a regression test - docs: blank line around lint list (cli.md), headings + code-span spacing (data-model.md) Deliberately not changed: emit_stdout's quiet EPIPE exit(0) is the tested contract (broken_stdout_pipe_exits_quietly), not a utility accident. --- .../contracts/cli.md | 1 + .../data-model.md | 6 +- src/cli/lint.rs | 38 +- src/reconcile/mod.rs | 98 +- src/reuse/atomic.rs | 120 +- src/reuse/oob.rs | 1828 ----------------- src/reuse/oob/lookup.rs | 189 ++ src/reuse/oob/mod.rs | 152 ++ src/reuse/oob/parse.rs | 361 ++++ src/reuse/oob/tests.rs | 784 +++++++ src/reuse/oob/write.rs | 378 ++++ src/walk/mod.rs | 160 +- 12 files changed, 2108 insertions(+), 2007 deletions(-) delete mode 100644 src/reuse/oob.rs create mode 100644 src/reuse/oob/lookup.rs create mode 100644 src/reuse/oob/mod.rs create mode 100644 src/reuse/oob/parse.rs create mode 100644 src/reuse/oob/tests.rs create mode 100644 src/reuse/oob/write.rs diff --git a/specs/001-declarative-license-headers/contracts/cli.md b/specs/001-declarative-license-headers/contracts/cli.md index 76f3ee0..f6f1a6c 100644 --- a/specs/001-declarative-license-headers/contracts/cli.md +++ b/specs/001-declarative-license-headers/contracts/cli.md @@ -160,6 +160,7 @@ licet init [--from-reuse] [--output ] [--config ] [--force] [--forma ``` licet lint [--allow-network] [--config ] ``` + - Reports REUSE conformance posture over **actual** metadata, independently of any declared policy: every covered file needs a license expression and a copyright notice (`missing_license` / `missing_copyright` diagnostics), malformed values diff --git a/specs/001-declarative-license-headers/data-model.md b/specs/001-declarative-license-headers/data-model.md index d1cf2bb..35fcb0a 100644 --- a/specs/001-declarative-license-headers/data-model.md +++ b/specs/001-declarative-license-headers/data-model.md @@ -173,9 +173,9 @@ reference REUSE 6.2.0 tool). License and copyright resolve independently: `candidate_licenses()` is the single precedence-aware resolver both `classify` and `reconcile` consult, so the rule is applied in exactly one place. `REUSE.toml` path patterns use the REUSE grammar (`*` never crosses `/`, `**` does, only `\`-escapes are -special, `?[]{} ` are literal); dep5 `Files:` patterns are shell-style (`*` crosses `/`). +special, `?[]{}` are literal); dep5 `Files:` patterns are shell-style (`*` crosses `/`). -**HeaderBlock** +### HeaderBlock | Field | Type | Notes | |-------|------|-------| @@ -265,7 +265,7 @@ The computed result of a `check` (read-only) or `apply` (writing) run. | `writes` | list of write records | Independent of file states: one record per planned/applied/failed/blocked write with destination, kind, outcome, covered files, and exact text for text writes (FR-021). | | `summary` | `{ pass, partial, complete, before_pass, projected_pass, counts }` | `counts` holds per-`DriftClass` totals **plus** `conflicts` and `contradictions`; `partial` (FR-021) and `pass` (FR-012a, SC-009) live here too. `complete` is false when final verification did not run; `before_pass` is the pre-apply gate; dry-run `pass` means `projected_pass`. Drives the exit code. This is the canonical shape; `report.schema.json` matches it. | -**PlannedWrite / write record** +### PlannedWrite / write record | Field | Type | Notes | |-------|------|-------| diff --git a/src/cli/lint.rs b/src/cli/lint.rs index 8656008..5fe28cd 100644 --- a/src/cli/lint.rs +++ b/src/cli/lint.rs @@ -21,6 +21,28 @@ use crate::reuse::inventory::LicenseTextInventory; use crate::spdx; use crate::walk::{Purpose, Selection, discover_root, prepare}; +/// Validate an explicitly supplied `--config`: it must exist and parse, and +/// its acceptance is reported as deprecated-and-ignored, since lint validates +/// actual REUSE metadata rather than declared policy. +fn check_explicit_config(cwd: &Path, cfg_path: &Path) -> Result<()> { + let abs = if cfg_path.is_absolute() { + cfg_path.to_path_buf() + } else { + cwd.join(cfg_path) + }; + let text = std::fs::read_to_string(&abs).map_err(|e| { + LicetError::Config(format!( + "cannot read explicit lint config `{}`: {e}", + abs.display() + )) + })?; + LicensingConfiguration::from_toml(&text)?; + eprintln!( + "warning: `lint --config` is deprecated and ignored: lint validates actual REUSE metadata, not declared policy" + ); + Ok(()) +} + pub fn run(args: LintArgs) -> Result { let cwd = std::env::current_dir()?; let (root, _) = discover_root(&cwd)?; @@ -29,21 +51,7 @@ pub fn run(args: LintArgs) -> Result { // otherwise); it is accepted with a deprecation notice but never changes // REUSE evaluation. if let Some(cfg_path) = &args.config { - let abs = if cfg_path.is_absolute() { - cfg_path.clone() - } else { - cwd.join(cfg_path) - }; - let text = std::fs::read_to_string(&abs).map_err(|e| { - LicetError::Config(format!( - "cannot read explicit lint config `{}`: {e}", - abs.display() - )) - })?; - LicensingConfiguration::from_toml(&text)?; - eprintln!( - "warning: `lint --config` is deprecated and ignored: lint validates actual REUSE metadata, not declared policy" - ); + check_explicit_config(&cwd, cfg_path)?; } // REUSE validation covers tracked plus nonignored untracked files and never diff --git a/src/reconcile/mod.rs b/src/reconcile/mod.rs index 4793c8e..ad6e547 100644 --- a/src/reconcile/mod.rs +++ b/src/reconcile/mod.rs @@ -75,47 +75,41 @@ impl PlanError { } } -/// Plan a file's reconciliation toward `intent` in the given `style`. -/// -/// Edits are span-based: license values are replaced at the exact byte spans -/// carried from detection, and appended lines are anchored to existing tag -/// lines with closers derived from `style` — never from re-parsed trailing -/// code. Copyright lines are never touched (FR-009). Returns [`PlanError`] -/// instead of guessing when the target block cannot be edited safely. -pub fn plan_file( +/// Plan the insert for a file with no in-file header, carrying the full intent. +/// A license already satisfied out-of-band needs no in-file record. +fn plan_missing_header( content: &str, - actual: &ActualLicenseState, intent: &LicenseIntent, style: &CommentSyntax, mode: ChangeMode, - target_header: Option, -) -> Result { - let candidates = candidate_licenses(actual); - let license_ok = candidates - .iter() - .any(|c| spdx::expressions_equal(c, &intent.license_expression)); - - // No in-file header → insert one carrying the full intent. - if actual.headers.is_empty() { - if license_ok { - // Covered out-of-band: nothing in-file to do. - return Ok(PlannedChange::noop(mode)); - } - let copyrights = copyrights_to_write(&[], &intent.copyright_policy); - let header = comment::render_header(style, &intent.license_expression, ©rights); - let new_content = insert::insert_header(content, &header); - return Ok(PlannedChange { - new_content: Some(new_content), - mode, - target_header: None, - wrote_header: true, - preserved_copyrights: copyrights.len(), - contradiction: false, - }); + license_ok: bool, +) -> PlannedChange { + if license_ok { + // Covered out-of-band: nothing in-file to do. + return PlannedChange::noop(mode); + } + let copyrights = copyrights_to_write(&[], &intent.copyright_policy); + let header = comment::render_header(style, &intent.license_expression, ©rights); + let new_content = insert::insert_header(content, &header); + PlannedChange { + new_content: Some(new_content), + mode, + target_header: None, + wrote_header: true, + preserved_copyrights: copyrights.len(), + contradiction: false, } +} - // Edit an existing header block. An explicit index is validated, never - // clamped: silently editing a different block than requested corrupts. +/// Resolve which detected header block to edit and prove its recorded spans +/// still address the file: an explicit out-of-range index, an out-of-bounds +/// byte range, or a value/span count mismatch is an invalid target, never a +/// guess at a different block. +fn resolve_target_block<'a>( + actual: &'a ActualLicenseState, + target_header: Option, + content: &str, +) -> Result<(usize, &'a HeaderBlock), PlanError> { let idx = match target_header { Some(t) if t >= actual.headers.len() => { return Err(PlanError::invalid(format!( @@ -144,6 +138,40 @@ pub fn plan_file( block.copyright_spans.len() ))); } + Ok((idx, block)) +} + +/// Plan a file's reconciliation toward `intent` in the given `style`. +/// +/// Edits are span-based: license values are replaced at the exact byte spans +/// carried from detection, and appended lines are anchored to existing tag +/// lines with closers derived from `style` — never from re-parsed trailing +/// code. Copyright lines are never touched (FR-009). Returns [`PlanError`] +/// instead of guessing when the target block cannot be edited safely. +pub fn plan_file( + content: &str, + actual: &ActualLicenseState, + intent: &LicenseIntent, + style: &CommentSyntax, + mode: ChangeMode, + target_header: Option, +) -> Result { + let candidates = candidate_licenses(actual); + let license_ok = candidates + .iter() + .any(|c| spdx::expressions_equal(c, &intent.license_expression)); + + // No in-file header → insert one carrying the full intent. + if actual.headers.is_empty() { + return Ok(plan_missing_header( + content, intent, style, mode, license_ok, + )); + } + + // Edit an existing header block. An explicit index is validated, never + // clamped: silently editing a different block than requested corrupts. + let (idx, block) = resolve_target_block(actual, target_header, content)?; + let (start, end) = block.byte_range; let preserved = block.copyrights.len(); // Copyright intent applies even when the license already matches (FR-007): diff --git a/src/reuse/atomic.rs b/src/reuse/atomic.rs index 87b52f4..bdbbf67 100644 --- a/src/reuse/atomic.rs +++ b/src/reuse/atomic.rs @@ -98,44 +98,22 @@ pub fn read_expected_for_write(path: &Path) -> io::Result>> { } } -/// Atomically replace (or create) `relative` under `root`. -/// -/// - `expected = None`: the destination must not exist (creation). -/// - `expected = Some(bytes)`: the destination must be a regular file whose -/// current bytes equal `bytes`; anything else aborts with no write. -/// - `replacement`: exact bytes to install. -/// -/// See the module docs for the containment checks applied to every call. -pub fn atomic_write( - root: &Path, +/// Destination states distinguished without following symlinks. +enum Dest { + Absent, + Present { permissions: std::fs::Permissions }, +} + +/// Classify the destination without following symlinks; permission/read +/// failures are errors, never absence (F13). Every refusal (symlink, +/// non-regular file, unexpected presence/absence/content) aborts before +/// any byte is staged or written. +fn classify_destination( + dest: &Path, relative: &Path, expected: Option<&[u8]>, - replacement: &[u8], -) -> Result<(), WriteError> { - let rel = join_checked(relative)?; - let canon_root = root.canonicalize().map_err(|e| { - WriteError::before(io::Error::new( - e.kind(), - format!("cannot resolve allowed root {}: {e}", root.display()), - )) - })?; - let dest = canon_root.join(&rel); - let parent = dest.parent().ok_or_else(|| { - WriteError::before(io::Error::new( - io::ErrorKind::InvalidInput, - format!("destination has no parent: {}", relative.display()), - )) - })?; - - check_ancestors(&canon_root, parent, relative)?; - - // Classify the destination without following symlinks; permission/read - // failures are errors, never absence (F13). - enum Dest { - Absent, - Present { permissions: std::fs::Permissions }, - } - let dest_state = match std::fs::symlink_metadata(&dest) { +) -> Result { + match std::fs::symlink_metadata(dest) { Err(e) if e.kind() == io::ErrorKind::NotFound => { if expected.is_some() { return Err(WriteError::before(io::Error::new( @@ -146,14 +124,12 @@ pub fn atomic_write( ), ))); } - Dest::Absent - } - Err(e) => { - return Err(WriteError::before(io::Error::new( - e.kind(), - format!("cannot stat destination {}: {e}", relative.display()), - ))); + Ok(Dest::Absent) } + Err(e) => Err(WriteError::before(io::Error::new( + e.kind(), + format!("cannot stat destination {}: {e}", relative.display()), + ))), Ok(meta) => { let ft = meta.file_type(); if is_link(&meta) { @@ -175,17 +151,15 @@ pub fn atomic_write( ))); } match expected { - None => { - return Err(WriteError::before(io::Error::new( - io::ErrorKind::AlreadyExists, - format!( - "destination {} already exists; refusing to create", - relative.display() - ), - ))); - } + None => Err(WriteError::before(io::Error::new( + io::ErrorKind::AlreadyExists, + format!( + "destination {} already exists; refusing to create", + relative.display() + ), + ))), Some(exp) => { - let current = std::fs::read(&dest).map_err(|e| { + let current = std::fs::read(dest).map_err(|e| { WriteError::before(io::Error::new( e.kind(), format!("cannot re-read destination {}: {e}", relative.display()), @@ -200,13 +174,47 @@ pub fn atomic_write( ), ))); } - Dest::Present { + Ok(Dest::Present { permissions: meta.permissions(), - } + }) } } } - }; + } +} + +/// Atomically replace (or create) `relative` under `root`. +/// +/// - `expected = None`: the destination must not exist (creation). +/// - `expected = Some(bytes)`: the destination must be a regular file whose +/// current bytes equal `bytes`; anything else aborts with no write. +/// - `replacement`: exact bytes to install. +/// +/// See the module docs for the containment checks applied to every call. +pub fn atomic_write( + root: &Path, + relative: &Path, + expected: Option<&[u8]>, + replacement: &[u8], +) -> Result<(), WriteError> { + let rel = join_checked(relative)?; + let canon_root = root.canonicalize().map_err(|e| { + WriteError::before(io::Error::new( + e.kind(), + format!("cannot resolve allowed root {}: {e}", root.display()), + )) + })?; + let dest = canon_root.join(&rel); + let parent = dest.parent().ok_or_else(|| { + WriteError::before(io::Error::new( + io::ErrorKind::InvalidInput, + format!("destination has no parent: {}", relative.display()), + )) + })?; + + check_ancestors(&canon_root, parent, relative)?; + + let dest_state = classify_destination(&dest, relative, expected)?; // Exclusively-created temp file in the destination directory: random name, so // a pre-existing predictable `.licet.tmp` (regular file or symlink) is diff --git a/src/reuse/oob.rs b/src/reuse/oob.rs deleted file mode 100644 index d974dab..0000000 --- a/src/reuse/oob.rs +++ /dev/null @@ -1,1828 +0,0 @@ -//! Out-of-band REUSE metadata: read `REUSE.toml` (current spec) and `.reuse/dep5` -//! (legacy) for detection, and write `REUSE.toml` annotations for non-annotatable -//! files (FR-015, FR-003a, research §7). -//! -//! `REUSE.toml` files are discovered at every directory depth through the same -//! content snapshot the scan evaluates, so staged checks observe staged -//! metadata. Hierarchy resolution mirrors the reference REUSE tool: documents -//! are consulted root-first and stop after the rootmost `override` table; -//! `aggregate` tables always contribute; `closest` tables are a per-field -//! fallback. Every contributing table keeps its provenance in -//! [`crate::domain::MetadataOrigin`]. - -use std::path::{Path, PathBuf}; - -use globset::{GlobBuilder, GlobMatcher}; -use serde::Deserialize; - -use crate::domain::{MetadataOrigin, OobSource, OutOfBandEntry, Precedence}; -use crate::walk::Snapshot; - -/// All out-of-band metadata discovered under a repository root. -#[derive(Debug, Default)] -pub struct OutOfBand { - /// Parsed `REUSE.toml` documents, shallowest directory first. - docs: Vec, - /// Parsed `.reuse/dep5` paragraphs in document order. - dep5: Vec, - /// Whether `.reuse/dep5` exists in the snapshot (even when it carries no - /// paragraphs — mere coexistence with any `REUSE.toml` is an error). - dep5_present: bool, -} - -/// One parsed `REUSE.toml` document plus where it lives. -#[derive(Debug)] -struct ReuseDoc { - /// Repo-relative path of the document (`REUSE.toml`, `sub/REUSE.toml`). - rel: PathBuf, - /// Directory containing the document (empty for the project root); - /// annotation paths are relative to this base and can never escape it. - base: PathBuf, - tables: Vec, -} - -/// One validated `[[annotations]]` table with its matchers precompiled. -#[derive(Debug)] -struct OobTable { - index: usize, - matchers: Vec, - licenses: Vec, - copyrights: Vec, - precedence: Precedence, -} - -/// One parsed `.reuse/dep5` paragraph (always `aggregate` per REUSE 3.3 -/// §"Order of precedence": dep5 licensing adds to file-level licensing). -#[derive(Debug)] -struct Dep5Para { - index: usize, - matchers: Vec, - licenses: Vec, - copyrights: Vec, -} - -/// A path matcher: either a compiled glob or a literal fallback for patterns -/// the glob compiler rejects (never a silent drop). -#[derive(Debug)] -enum ReuseMatcher { - Glob(GlobMatcher), - Literal(String), -} - -impl ReuseMatcher { - fn is_match(&self, path: &str) -> bool { - match self { - ReuseMatcher::Glob(g) => g.is_match(path), - ReuseMatcher::Literal(l) => l == path, - } - } -} - -#[derive(Debug, Deserialize)] -struct ReuseToml { - version: u32, - #[serde(default)] - annotations: Vec, -} - -#[derive(Debug, Deserialize)] -struct ReuseAnnotation { - path: StringList, - #[serde(rename = "SPDX-License-Identifier", default)] - license: StringList, - #[serde(rename = "SPDX-FileCopyrightText", default)] - copyright: StringList, - /// REUSE 3.3 `precedence`: exactly `closest` (default) | `aggregate` | - /// `override`. Anything else is a malformed document, not a silent default. - precedence: Option, -} - -/// A TOML string-or-list field (`path`, licensing, copyright). -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum StringList { - One(String), - Many(Vec), -} - -impl Default for StringList { - fn default() -> Self { - StringList::Many(Vec::new()) - } -} - -impl StringList { - fn into_vec(self) -> Vec { - match self { - StringList::One(s) => vec![s], - StringList::Many(v) => v, - } - } -} - -impl OutOfBand { - /// Load `REUSE.toml` documents at every depth plus `.reuse/dep5` from the - /// working tree at `root`. Fallible: malformed metadata is an error with - /// document path and location, never silent absence (F08, F09, F13). - pub fn load(root: &Path) -> crate::error::Result { - Self::load_snapshot(&Snapshot::Worktree { - root: root.to_path_buf(), - }) - } - - /// Load metadata through a content snapshot (staged checks observe staged - /// metadata, never the working copy). - /// - /// Read failures and non-UTF-8 metadata are errors, never silent absence - /// (F04, F13). Malformed documents, a `version` other than 1, a missing - /// `path`, an invalid `precedence`, an invalid license expression, and - /// `REUSE.toml`+`.reuse/dep5` coexistence all fail here — before any - /// write — carrying the document path and parse location. - pub fn load_snapshot(snapshot: &Snapshot) -> crate::error::Result { - use crate::error::LicetError; - let mut oob = OutOfBand::default(); - for rel in discover_doc_paths(snapshot)? { - let bytes = snapshot.read(&rel)?.ok_or_else(|| { - LicetError::Config(format!( - "REUSE metadata {} disappeared during the scan", - rel.display() - )) - })?; - let text = String::from_utf8(bytes).map_err(|e| { - LicetError::Config(format!( - "REUSE metadata {} is not valid UTF-8: {e}", - rel.display() - )) - })?; - oob.parse_reuse_toml(&rel, &text)?; - } - let dep5_rel = Path::new(".reuse/dep5"); - if let Some(bytes) = snapshot.read(dep5_rel)? { - oob.dep5_present = true; - let text = String::from_utf8(bytes).map_err(|e| { - LicetError::Config(format!( - "REUSE metadata .reuse/dep5 is not valid UTF-8: {e}" - )) - })?; - oob.parse_dep5(&text)?; - } - if oob.dep5_present && !oob.docs.is_empty() { - return Err(LicetError::Config(format!( - "found both '{}' and '.reuse/dep5': REUSE.toml and DEP5 are mutually \ - exclusive, you cannot keep both simultaneously", - display_rel(&oob.docs[0].rel) - ))); - } - Ok(oob) - } - - /// Parse one `REUSE.toml` document: structural TOML errors, a `version` - /// other than 1, a missing/empty `path`, an invalid `precedence`, and an - /// invalid license expression all fail with document path and table index. - /// Unknown keys and tables are preserved-ignored per the REUSE schema's - /// extension allowance. - fn parse_reuse_toml(&mut self, rel: &Path, text: &str) -> crate::error::Result<()> { - use crate::error::LicetError; - let loc = display_rel(rel); - let parsed: ReuseToml = toml::from_str(text) - .map_err(|e| LicetError::Config(format!("{loc}: malformed REUSE.toml: {e}")))?; - if parsed.version != 1 { - return Err(LicetError::Config(format!( - "{loc}: unsupported REUSE.toml version {} (this tool implements version 1)", - parsed.version - ))); - } - let base = rel.parent().map(Path::to_path_buf).unwrap_or_default(); - let mut tables = Vec::with_capacity(parsed.annotations.len()); - for (index, ann) in parsed.annotations.into_iter().enumerate() { - let tag = format!("{loc} table [[annotations]] #{index}"); - let paths = ann.path.into_vec(); - if paths.is_empty() { - return Err(LicetError::Config(format!( - "{tag}: 'path' must not be empty" - ))); - } - let precedence = match ann.precedence.as_deref() { - None => Precedence::Closest, - Some("closest") => Precedence::Closest, - Some("aggregate") => Precedence::Aggregate, - Some("override") => Precedence::Override, - Some(other) => { - return Err(LicetError::Config(format!( - "{tag}: invalid precedence {other:?}: must be one of \ - 'closest', 'aggregate', 'override'" - ))); - } - }; - let mut licenses = Vec::new(); - for raw in ann.license.into_vec() { - crate::spdx::validate_expression(&raw).map_err(|reason| { - LicetError::Config(format!("{tag}: invalid SPDX-License-Identifier: {reason}")) - })?; - licenses.push(raw.trim().to_string()); - } - let copyrights = ann - .copyright - .into_vec() - .into_iter() - .map(|c| c.trim().to_string()) - .filter(|c| !c.is_empty()) - .collect(); - let matchers = paths.iter().map(|p| compile_reuse_pattern(p)).collect(); - tables.push(OobTable { - index, - matchers, - licenses, - copyrights, - precedence, - }); - } - self.docs.push(ReuseDoc { - rel: rel.to_path_buf(), - base, - tables, - }); - // Keep documents ordered shallowest-first so lookup consults them - // root-first (mirrors the reference tool). - self.docs.sort_by_key(|d| d.base.components().count()); - Ok(()) - } - - /// Debian dep5 (`.reuse/dep5`) parser: `Files:`/`Copyright:`/`License:` - /// paragraphs (field names ASCII-case-insensitive) with continuation-line - /// unfolding — a line starting with whitespace continues the current - /// field, and a lone `.` is a blank. Unknown fields are preserved-ignored - /// without being interpreted. An invalid `License:` expression fails like - /// any other malformed metadata. - fn parse_dep5(&mut self, text: &str) -> crate::error::Result<()> { - use crate::error::LicetError; - #[derive(Default)] - struct Para { - files: String, - copyright: Vec, - license: String, - field: Option, - } - #[derive(Clone, Copy, PartialEq, Eq)] - enum Dep5Field { - Files, - Copyright, - License, - Other, - } - let mut paras: Vec = vec![Para::default()]; - for raw_line in text.lines() { - let line = raw_line.strip_suffix('\r').unwrap_or(raw_line); - if line.trim().is_empty() { - paras.push(Para::default()); - continue; - } - let para = paras.last_mut().expect("paragraphs never empty"); - if line.starts_with(' ') || line.starts_with('\t') { - // Continuation of the current field (a lone `.` is blank). - let cont = line.trim(); - if cont.is_empty() || cont == "." { - continue; - } - match para.field { - Some(Dep5Field::Files) => { - para.files.push(' '); - para.files.push_str(cont); - } - Some(Dep5Field::Copyright) => para.copyright.push(cont.to_string()), - Some(Dep5Field::License) => { - para.license.push(' '); - para.license.push_str(cont); - } - Some(Dep5Field::Other) | None => {} - } - continue; - } - if cont_is_dot_only(line) { - continue; - } - let (name, value) = match line.split_once(':') { - Some((n, v)) => (n.trim(), v.trim()), - None => continue, - }; - if name.eq_ignore_ascii_case("Files") { - para.field = Some(Dep5Field::Files); - if !para.files.is_empty() { - para.files.push(' '); - } - para.files.push_str(value); - } else if name.eq_ignore_ascii_case("Copyright") { - para.field = Some(Dep5Field::Copyright); - if !value.is_empty() { - para.copyright.push(value.to_string()); - } - } else if name.eq_ignore_ascii_case("License") { - para.field = Some(Dep5Field::License); - if !para.license.is_empty() { - para.license.push(' '); - } - para.license.push_str(value); - } else { - para.field = Some(Dep5Field::Other); - } - } - for (index, para) in paras.into_iter().enumerate() { - if para.files.trim().is_empty() { - continue; - } - let license = para.license.trim(); - let mut licenses = Vec::new(); - if !license.is_empty() { - crate::spdx::validate_expression(license).map_err(|reason| { - LicetError::Config(format!( - ".reuse/dep5 paragraph #{index}: invalid License: {reason}" - )) - })?; - licenses.push(license.to_string()); - } - let matchers = para - .files - .split_whitespace() - .map(compile_dep5_pattern) - .collect(); - self.dep5.push(Dep5Para { - index, - matchers, - licenses, - copyrights: para.copyright, - }); - } - Ok(()) - } - - /// Out-of-band coverage for a repo-relative path, if any. - /// - /// Each document contributes exclusively its last matching table; tables - /// are consulted root-first and consultation stops after the rootmost - /// `override` table (mirroring the reference tool). The returned entry - /// carries unconditional (`aggregate`/barrier) values, per-field - /// `closest` fallbacks, and every contributing table's provenance. - pub fn lookup(&self, rel_path: &Path) -> Option { - let rel_str = rel_path.to_string_lossy().replace('\\', "/"); - // Per-document last match, root-first, stopping after an override. - let mut consulted: Vec<(&ReuseDoc, &OobTable)> = Vec::new(); - for doc in &self.docs { - let Some(remainder) = under_base(&rel_str, &doc.base) else { - continue; - }; - if let Some(table) = doc - .tables - .iter() - .rev() - .find(|t| matches_any(&t.matchers, remainder)) - { - let is_override = table.precedence == Precedence::Override; - consulted.push((doc, table)); - if is_override { - break; - } - } - } - // dep5 paragraphs aggregate; the last matching paragraph wins (as in - // the reference tool), contributing alongside any REUSE tables. - let dep5_hit = self - .dep5 - .iter() - .rev() - .find(|p| matches_any(&p.matchers, &rel_str)); - if consulted.is_empty() && dep5_hit.is_none() { - return None; - } - let barrier = consulted - .iter() - .any(|(_, t)| t.precedence == Precedence::Override); - let mut licenses: Vec = Vec::new(); - let mut copyrights: Vec = Vec::new(); - let mut origins: Vec = Vec::new(); - let mut primary_source = OobSource::ReuseToml; - let mut has_aggregate = false; - // Unconditional contributors: the barrier table first (if any), then - // every consulted `aggregate` table shallowest-first, then dep5 — - // matching the reference tool's override-before-aggregate order. - let mut unconditional: Vec<(&ReuseDoc, &OobTable)> = Vec::new(); - if barrier { - let (doc, table) = consulted - .last() - .expect("barrier implies a consulted override table"); - debug_assert_eq!(table.precedence, Precedence::Override); - unconditional.push((*doc, *table)); - } - for (doc, table) in &consulted { - if table.precedence == Precedence::Aggregate { - has_aggregate = true; - unconditional.push((*doc, *table)); - } - } - for (doc, table) in unconditional { - push_unique(&mut licenses, table.licenses.iter().cloned()); - push_unique(&mut copyrights, table.copyrights.iter().cloned()); - origins.push(MetadataOrigin { - metadata_path: doc.rel.clone(), - table_index: table.index, - precedence: table.precedence, - licenses: table.licenses.clone(), - copyrights: table.copyrights.clone(), - }); - } - if let Some(para) = dep5_hit { - has_aggregate = true; - if origins.is_empty() { - primary_source = OobSource::Dep5; - } - push_unique(&mut licenses, para.licenses.iter().cloned()); - push_unique(&mut copyrights, para.copyrights.iter().cloned()); - origins.push(MetadataOrigin { - metadata_path: PathBuf::from(".reuse/dep5"), - table_index: para.index, - precedence: Precedence::Aggregate, - licenses: para.licenses.clone(), - copyrights: para.copyrights.clone(), - }); - } - // Per-field nearest-outward `closest` fallback (deepest consulted - // table wins each field independently). - let mut fallback_licenses: Vec = Vec::new(); - let mut fallback_copyrights: Vec = Vec::new(); - let mut fallback_lic_origin: Option = None; - let mut fallback_cpr_origin: Option = None; - for (doc, table) in consulted.iter().rev() { - if table.precedence != Precedence::Closest { - continue; - } - if fallback_licenses.is_empty() && !table.licenses.is_empty() { - fallback_licenses = table.licenses.clone(); - fallback_lic_origin = Some(MetadataOrigin { - metadata_path: doc.rel.clone(), - table_index: table.index, - precedence: table.precedence, - licenses: table.licenses.clone(), - copyrights: table.copyrights.clone(), - }); - } - if fallback_copyrights.is_empty() && !table.copyrights.is_empty() { - fallback_copyrights = table.copyrights.clone(); - fallback_cpr_origin = Some(MetadataOrigin { - metadata_path: doc.rel.clone(), - table_index: table.index, - precedence: table.precedence, - licenses: table.licenses.clone(), - copyrights: table.copyrights.clone(), - }); - } - } - if fallback_lic_origin - .as_ref() - .is_some_and(|o| Some(o) != fallback_cpr_origin.as_ref()) - { - origins.push(fallback_lic_origin.expect("checked")); - } - if let Some(o) = fallback_cpr_origin { - origins.push(o); - } - if licenses.is_empty() - && copyrights.is_empty() - && fallback_licenses.is_empty() - && fallback_copyrights.is_empty() - { - // Tables matched but none carries any licensing information — an - // override barrier still suppresses the file (reference behavior), - // otherwise there is no coverage at all. - if !barrier { - return None; - } - } - // Origins read shallowest-document first (dep5 is root-level). - origins.sort_by_key(|o| (o.metadata_path.clone(), o.table_index)); - let precedence = if barrier { - Precedence::Override - } else if has_aggregate { - Precedence::Aggregate - } else { - Precedence::Closest - }; - Some(OutOfBandEntry { - source: primary_source, - licenses, - copyrights, - fallback_licenses, - fallback_copyrights, - suppresses_file: barrier, - precedence, - origins, - }) - } - - pub fn is_empty(&self) -> bool { - self.docs.is_empty() && self.dep5.is_empty() - } - - /// Whether `.reuse/dep5` exists in the loaded snapshot (used to refuse - /// writes that would create a mutually-exclusive `REUSE.toml` next to it). - pub fn has_dep5(&self) -> bool { - self.dep5_present - } -} - -/// A lone `.` line outside a field continuation is blank padding, not content. -fn cont_is_dot_only(line: &str) -> bool { - line.trim() == "." -} - -/// Display a snapshot-relative path with forward slashes. -fn display_rel(rel: &Path) -> String { - rel.to_string_lossy().replace('\\', "/") -} - -/// The portion of repo-relative `path` under document base `base`, or `None` -/// when the file is not inside that directory. Containment holds by -/// construction: a pattern can never match outside its own document's tree. -fn under_base<'a>(path: &'a str, base: &Path) -> Option<&'a str> { - let base_str = display_rel(base); - if base_str.is_empty() { - return Some(path); - } - path.strip_prefix(base_str.as_str()) - .and_then(|rest| rest.strip_prefix('/')) - .filter(|rest| !rest.is_empty()) -} - -fn matches_any(matchers: &[ReuseMatcher], path: &str) -> bool { - matchers.iter().any(|m| m.is_match(path)) -} - -fn push_unique(target: &mut Vec, iter: impl Iterator) { - for item in iter { - if !target.contains(&item) { - target.push(item); - } - } -} - -/// Compile one `REUSE.toml` path pattern with the REUSE 3.3 grammar: `*` -/// never crosses `/`, `**` (and `**/`) does, only `\`-asterisk and -/// `\`-backslash escapes are special, and `?`, brackets, and braces are -/// literal. Everything else is passed through to globset with `/` as the -/// separator, so a pattern can only ever match inside its document's tree. -fn compile_reuse_pattern(pattern: &str) -> ReuseMatcher { - let mut out = String::with_capacity(pattern.len()); - let mut chars = pattern.chars().peekable(); - while let Some(c) = chars.next() { - match c { - '\\' => match chars.next() { - // `\` followed by any other character is that character - // verbatim (spec §REUSE.toml); escape it for globset. - Some(next) => { - out.push('\\'); - out.push(next); - } - None => out.push_str("[\\\\]"), - }, - '*' => { - let mut run = 1; - while chars.peek() == Some(&'*') { - chars.next(); - run += 1; - } - out.push_str(if run >= 2 { "**" } else { "*" }); - } - '?' | '[' | ']' | '{' | '}' => { - out.push('\\'); - out.push(c); - } - _ => out.push(c), - } - } - let full = if out.is_empty() { - "**".to_string() - } else { - out - }; - match GlobBuilder::new(&full).literal_separator(true).build() { - Ok(g) => ReuseMatcher::Glob(g.compile_matcher()), - Err(_) => ReuseMatcher::Literal(pattern.to_string()), - } -} - -/// Compile one `.reuse/dep5` `Files:` pattern: shell-style wildcards where -/// `*` crosses `/` (verified against the reference tool), unlike REUSE.toml. -fn compile_dep5_pattern(pattern: &str) -> ReuseMatcher { - match GlobBuilder::new(pattern).literal_separator(false).build() { - Ok(g) => ReuseMatcher::Glob(g.compile_matcher()), - Err(_) => ReuseMatcher::Literal(pattern.to_string()), - } -} - -/// Repo-relative paths of every `REUSE.toml` document visible in `snapshot`: -/// tracked-or-present files at any depth that are not VCS-ignored. Index -/// snapshots observe tracked entries (tracked files are never VCS-ignored); -/// worktree snapshots walk the filesystem honoring ignore rules, never -/// following symlinks. -fn discover_doc_paths(snapshot: &Snapshot) -> crate::error::Result> { - match snapshot { - Snapshot::Index(index) => { - let mut paths: Vec = index - .entries - .keys() - .filter(|p| { - p.file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n == "REUSE.toml") - }) - .cloned() - .collect(); - paths.sort(); - Ok(paths) - } - Snapshot::Worktree { root } => { - use ignore::{WalkBuilder, overrides::OverrideBuilder}; - let mut ob = OverrideBuilder::new(root); - ob.add("!.git/") - .expect("valid built-in skip override for metadata discovery"); - let overrides = ob.build().expect("valid metadata-discovery overrides"); - let walker = WalkBuilder::new(root) - .hidden(false) - .git_ignore(true) - .git_global(true) - .parents(true) - .overrides(overrides) - .follow_links(false) - .build(); - let mut paths = Vec::new(); - for entry in walker { - let entry = match entry { - Ok(e) => e, - Err(e) => { - return Err(crate::error::LicetError::Io(std::io::Error::other( - format!("metadata discovery traversal failed: {e}"), - ))); - } - }; - if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) { - continue; - } - if entry.file_name().to_str().is_none_or(|n| n != "REUSE.toml") { - continue; - } - if let Ok(rel) = entry.path().strip_prefix(root) { - paths.push(rel.to_path_buf()); - } - } - paths.sort(); - Ok(paths) - } - } -} - -/// Outcome of [`write_annotation`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AnnotationWrite { - /// The file's effective `REUSE.toml` license already matched; nothing written. - Unchanged, - /// A new exact-path annotation was appended — covering a previously - /// uncovered file, overriding a broader glob entry, or superseding an - /// older exact-path entry. Being last, it wins per REUSE 3.3. - Appended, -} - -impl AnnotationWrite { - /// Whether the file was actually rewritten. - pub fn modified(self) -> bool { - !matches!(self, AnnotationWrite::Unchanged) - } -} - -/// One exact-path annotation the caller wants covered by `REUSE.toml`. -pub struct AnnotationRequest<'a> { - pub rel_path: &'a str, - pub license: &'a str, - pub copyrights: &'a [String], -} - -/// Where one annotation patch belongs: the document that wins for the path, -/// the path relative to that document's base, and whether the stanza needs -/// `override` precedence to govern there. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AnnotationDestination { - /// Repo-relative document path (`REUSE.toml`, `sub/REUSE.toml`). - pub doc_rel: PathBuf, - /// Request path relative to the document's base. - pub base_path: String, - /// True when only an `override` stanza governs at the destination: under - /// an `override` barrier, or when an `aggregate` table contributes - /// unconditionally (a default-`closest` stanza would neither break the - /// barrier nor silence the aggregate). - pub use_override: bool, -} - -/// Route an annotation to its winning document (FR-003a). -/// -/// The walk mirrors [`OutOfBand::lookup`] over the already-loaded hierarchy: -/// - no coverage, or `closest` governing → the nearest consulted document -/// (the project root when nothing consults), default precedence — nearest -/// wins the per-field `closest` fallback; -/// - `override` or `aggregate` governing → the project root with `override` -/// precedence — root is consulted first, so the appended stanza breaks -/// before every deeper table and supersedes root tables by last match. -pub fn annotation_destination(oob: &OutOfBand, rel_path: &str) -> AnnotationDestination { - let mut consulted: Vec<(usize, Precedence)> = Vec::new(); - for (di, doc) in oob.docs.iter().enumerate() { - let Some(remainder) = under_base(rel_path, &doc.base) else { - continue; - }; - if let Some(table) = doc - .tables - .iter() - .rev() - .find(|t| matches_any(&t.matchers, remainder)) - { - let is_override = table.precedence == Precedence::Override; - consulted.push((di, table.precedence)); - if is_override { - break; - } - } - } - let governing = if consulted.iter().any(|(_, p)| *p == Precedence::Override) { - Some(Precedence::Override) - } else if consulted.iter().any(|(_, p)| *p == Precedence::Aggregate) { - Some(Precedence::Aggregate) - } else if consulted.is_empty() { - None - } else { - Some(Precedence::Closest) - }; - match governing { - Some(Precedence::Override) | Some(Precedence::Aggregate) => AnnotationDestination { - doc_rel: PathBuf::from("REUSE.toml"), - base_path: rel_path.to_string(), - use_override: true, - }, - Some(Precedence::Closest) => { - let (di, _) = consulted.last().copied().expect("consulted non-empty"); - let doc = &oob.docs[di]; - AnnotationDestination { - doc_rel: doc.rel.clone(), - base_path: under_base(rel_path, &doc.base) - .unwrap_or(rel_path) - .to_string(), - use_override: false, - } - } - None => AnnotationDestination { - doc_rel: PathBuf::from("REUSE.toml"), - base_path: rel_path.to_string(), - use_override: false, - }, - } -} - -/// Ensure a `REUSE.toml` annotation covers `rel_path` with `license` (FR-015, FR-003a). -/// -/// Single-request form of [`write_annotations`]; `oob` is the scan's loaded -/// hierarchy, used for coverage and destination routing. -pub fn write_annotation( - root: &Path, - rel_path: &str, - license: &str, - copyrights: &[String], - oob: &OutOfBand, -) -> Result { - let outcomes = write_annotations( - root, - &[AnnotationRequest { - rel_path, - license, - copyrights, - }], - oob, - )?; - Ok(outcomes - .per_request - .into_iter() - .next() - .unwrap_or(AnnotationWrite::Unchanged)) -} - -/// One patched metadata document: the before/after bytes plus which caller -/// requests its stanzas carry, so the caller files one write record per -/// document with the covered assets attached. -#[derive(Debug, Clone)] -pub struct DocPatch { - pub doc_rel: PathBuf, - pub before: Option>, - pub after: Vec, - pub requests: Vec, -} - -/// Outcome of [`write_annotations`]: per-request dispositions plus the -/// document patches actually committed (one per touched document). -#[derive(Debug, Clone)] -pub struct AnnotationBatchOutcome { - pub per_request: Vec, - pub patches: Vec, -} - -/// Ensure `REUSE.toml` annotations cover every request (FR-015, FR-003a). -/// -/// `oob` is the scan's loaded hierarchy: coverage and destination routing read -/// it, so decisions match what detection sees. Each request whose effective -/// license *and* copyrights already match is left untouched -/// ([`AnnotationWrite::Unchanged`]); every other request gains one appended -/// exact-path stanza ([`AnnotationWrite::Appended`]) in its winning document -/// ([`annotation_destination`]). Creates the project root document with a -/// `version = 1` header if absent; never creates deeper documents. -/// -/// The patch is strictly append-only: existing documents are never reparsed -/// with a lossy line scan and never reformatted, so comments, ordering, and -/// unrelated stanzas survive byte-for-byte, and a repeated run converges. -/// Requests group by destination document, so each document is read, patched, -/// and written exactly once. Every patched document is re-parsed — and every -/// appended coverage verified — before anything is committed. Copyrights -/// serialize as one string/list field, never repeated keys; exact paths escape -/// REUSE glob metacharacters so odd filenames cannot become new patterns; -/// appended stanzas reuse their document's own newline convention. -/// -/// An unreadable or non-UTF-8 document is an error, never silently replaced -/// with fresh content (F04). -pub fn write_annotations( - root: &Path, - requests: &[AnnotationRequest<'_>], - oob: &OutOfBand, -) -> Result { - use crate::reuse::{WriteError, atomic_write, read_expected_for_write}; - use std::collections::BTreeMap; - - let mut per_request = vec![AnnotationWrite::Unchanged; requests.len()]; - // Requests needing a stanza, grouped by destination document so each - // document is read, patched, and written exactly once. - let mut pending: BTreeMap> = BTreeMap::new(); - for (i, req) in requests.iter().enumerate() { - let entry = oob.lookup(Path::new(req.rel_path)); - let covered = entry.as_ref().and_then(|e| { - e.license() - .or_else(|| crate::domain::combine_licenses(&e.fallback_licenses)) - }); - let copyrights_match = entry.as_ref().is_some_and(|e| { - norm_set(effective_copyrights(e)) == norm_set(req.copyrights.to_vec()) - }); - if covered.as_deref() == Some(req.license) && copyrights_match { - continue; - } - let dest = annotation_destination(oob, req.rel_path); - pending - .entry(dest.doc_rel.clone()) - .or_default() - .push((i, dest)); - } - if pending.is_empty() { - return Ok(AnnotationBatchOutcome { - per_request, - patches: Vec::new(), - }); - } - - let mut patches: Vec = Vec::new(); - - for (doc_rel, jobs) in &pending { - let existing_bytes = - read_expected_for_write(&root.join(doc_rel)).map_err(WriteError::for_read_failure)?; - let existing = match &existing_bytes { - Some(bytes) => std::str::from_utf8(bytes).map_err(|e| { - WriteError::for_read_failure(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("existing {} is not valid UTF-8: {e}", doc_rel.display()), - )) - })?, - // Only the project root document may be created; a missing deeper - // document means the hierarchy moved under us — refuse, don't invent. - None if doc_rel == Path::new("REUSE.toml") => "", - None => { - return Err(WriteError::for_read_failure(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!( - "annotated document {} disappeared during the scan", - doc_rel.display() - ), - ))); - } - }; - - let nl = if existing.contains("\r\n") { - "\r\n" - } else { - "\n" - }; - let mut out = existing.to_owned(); - if out.is_empty() { - out.push_str("version = 1"); - out.push_str(nl); - out.push_str(nl); - } else if !out.ends_with('\n') { - out.push_str(nl); - } - for (i, dest) in jobs { - let req = &requests[*i]; - out.push_str("[[annotations]]"); - out.push_str(nl); - out.push_str(&format!( - "path = {}", - toml_string(&escape_reuse_path(&dest.base_path)) - )); - out.push_str(nl); - if dest.use_override { - out.push_str("precedence = \"override\""); - out.push_str(nl); - } - match req.copyrights.len() { - 0 => {} - 1 => { - out.push_str(&format!( - "SPDX-FileCopyrightText = {}", - toml_string(&req.copyrights[0]) - )); - out.push_str(nl); - } - _ => { - let list = req - .copyrights - .iter() - .map(|s| toml_string(s)) - .collect::>() - .join(", "); - out.push_str(&format!("SPDX-FileCopyrightText = [{list}]")); - out.push_str(nl); - } - } - out.push_str(&format!( - "SPDX-License-Identifier = {}", - toml_string(req.license) - )); - out.push_str(nl); - out.push_str(nl); - per_request[*i] = AnnotationWrite::Appended; - } - - // Re-parse the complete proposed document before committing, and - // verify every appended coverage through the same lookup detection - // uses (this also validates the escaping and list serialization). - let mut verify = OutOfBand::default(); - verify.parse_reuse_toml(doc_rel, &out).map_err(|e| { - WriteError::for_read_failure(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("proposed {} failed to re-parse: {e}", doc_rel.display()), - )) - })?; - for (i, _) in jobs { - let req = &requests[*i]; - // Verify through the document's own base, exactly as detection - // will read it back. - let got = verify.lookup(Path::new(req.rel_path)).and_then(|e| { - e.license() - .or_else(|| crate::domain::combine_licenses(&e.fallback_licenses)) - }); - if got.as_deref() != Some(req.license) { - return Err(WriteError::for_read_failure(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!( - "proposed {} does not cover {} with {}", - doc_rel.display(), - req.rel_path, - req.license - ), - ))); - } - } - - atomic_write(root, doc_rel, existing_bytes.as_deref(), out.as_bytes())?; - patches.push(DocPatch { - doc_rel: doc_rel.clone(), - before: existing_bytes.clone(), - after: out.into_bytes(), - requests: jobs.iter().map(|(i, _)| *i).collect(), - }); - } - Ok(AnnotationBatchOutcome { - per_request, - patches, - }) -} - -/// Effective OOB copyrights for a lookup entry, mirroring the license logic: -/// unconditional contributors first, else the `closest` fallback. -fn effective_copyrights(entry: &crate::domain::OutOfBandEntry) -> Vec { - if entry.copyrights.is_empty() { - entry.fallback_copyrights.clone() - } else { - entry.copyrights.clone() - } -} - -/// Order-insensitive comparison form for notice lists. -fn norm_set(mut values: Vec) -> Vec { - values.sort(); - values.dedup(); - values -} - -/// Escape the REUSE glob metacharacters in an exact path so the emitted stanza -/// cannot act as a pattern: a file named `a*b.png` must not start covering -/// its siblings. Only `\` and `*` are escaped: both engines treat `\`-asterisk -/// and `\\`-backslash as escapes, while `?`, brackets, and braces are literal -/// in both grammars and must stay bare (this crate's matcher and the reference -/// tool's pathspec grammar agree on all of this). -fn escape_reuse_path(path: &str) -> String { - let mut out = String::with_capacity(path.len()); - for c in path.chars() { - if matches!(c, '\\' | '*') { - out.push('\\'); - } - out.push(c); - } - out -} - -/// Quote a value for emission into a `REUSE.toml` stanza (write-side escaping only). -fn toml_string(s: &str) -> String { - format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) -} - -// REUSE-IgnoreStart — SPDX tags in the tests below are fixtures, not this file's licensing. -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - fn load_doc(text: &str) -> OutOfBand { - let mut oob = OutOfBand::default(); - oob.parse_reuse_toml(Path::new("REUSE.toml"), text).unwrap(); - oob - } - - #[test] - fn parses_reuse_toml_glob() { - // A `closest` table is fallback only: its values land in the fallback - // fields, leaving the unconditional ones empty for detection to fill - // from file-level info first. - let oob = load_doc( - "version = 1\n[[annotations]]\npath = \"assets/**\"\n\ - SPDX-License-Identifier = \"CC0-1.0\"\nSPDX-FileCopyrightText = \"2026 Acme\"\n", - ); - let e = oob.lookup(&PathBuf::from("assets/logo.png")).unwrap(); - assert!(e.licenses.is_empty()); - assert_eq!(e.fallback_licenses, vec!["CC0-1.0".to_string()]); - assert_eq!(e.fallback_copyrights, vec!["2026 Acme".to_string()]); - assert_eq!(e.precedence, Precedence::Closest); - assert!(!e.suppresses_file); - } - - #[test] - fn license_array_stays_together() { - // Multiple expressions in one table apply together (aggregate here so - // they are unconditional); the presentation form AND-combines them. - let oob = load_doc( - "version = 1\n[[annotations]]\npath = \"a.bin\"\nprecedence = \"aggregate\"\n\ - SPDX-License-Identifier = [\"MIT\", \"Apache-2.0\"]\n", - ); - let e = oob.lookup(&PathBuf::from("a.bin")).unwrap(); - assert_eq!( - e.licenses, - vec!["MIT".to_string(), "Apache-2.0".to_string()] - ); - assert_eq!(e.license().as_deref(), Some("MIT AND Apache-2.0")); - } - - #[test] - fn parses_dep5() { - let mut oob = OutOfBand::default(); - oob.parse_dep5("Files: img/*\nCopyright: 2026 Acme\nLicense: MIT\n") - .unwrap(); - let e = oob.lookup(&PathBuf::from("img/x.jpg")).unwrap(); - assert_eq!(e.licenses, vec!["MIT".to_string()]); - assert_eq!(e.source, OobSource::Dep5); - assert_eq!(e.precedence, Precedence::Aggregate); - } - - #[test] - fn dep5_star_crosses_directories() { - // Unlike REUSE.toml, dep5 `*` matches across `/` (reference behavior). - let mut oob = OutOfBand::default(); - oob.parse_dep5("Files: *.bin\nLicense: MIT\n").unwrap(); - assert!(oob.lookup(&PathBuf::from("sub/b.bin")).is_some()); - } - - #[test] - fn dep5_continuation_lines_unfold() { - let mut oob = OutOfBand::default(); - oob.parse_dep5( - "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\ - \n\ - Files: a.bin\n sub/b.bin\n\ - Copyright: 2026 A\n 2026 B\n .\n\ - License: MIT\n", - ) - .unwrap(); - let e = oob.lookup(&PathBuf::from("sub/b.bin")).unwrap(); - assert_eq!(e.licenses, vec!["MIT".to_string()]); - assert_eq!( - e.copyrights, - vec!["2026 A".to_string(), "2026 B".to_string()] - ); - } - - #[test] - fn dep5_last_paragraph_wins() { - // Two paragraphs covering the same file: the last one governs, exactly - // as the reference tool reports. - let mut oob = OutOfBand::default(); - oob.parse_dep5( - "Files: a.bin\nCopyright: 2026 A\nLicense: MIT\n\nFiles: a.bin\nCopyright: 2026 B\nLicense: Apache-2.0\n", - ) - .unwrap(); - let e = oob.lookup(&PathBuf::from("a.bin")).unwrap(); - assert_eq!(e.licenses, vec!["Apache-2.0".to_string()]); - assert_eq!(e.copyrights, vec!["2026 B".to_string()]); - assert_eq!(e.origins.len(), 1); - assert_eq!(e.origins[0].table_index, 1); - } - - #[test] - fn dep5_field_names_are_case_insensitive() { - let mut oob = OutOfBand::default(); - oob.parse_dep5("files: a.bin\ncopyright: 2026 A\nlicense: MIT\n") - .unwrap(); - let e = oob.lookup(&PathBuf::from("a.bin")).unwrap(); - assert_eq!(e.licenses, vec!["MIT".to_string()]); - } - - #[test] - fn no_match_is_none() { - let oob = OutOfBand::default(); - assert!(oob.lookup(&PathBuf::from("x")).is_none()); - } - - #[test] - fn malformed_documents_fail_with_location() { - for (name, text) in [ - ( - "bad-toml", - "version = 1\n[[annotations]]\npath = \nSPDX-License-Identifier = \"MIT\"\n", - ), - ("version-2", "version = 2\n[[annotations]]\npath = \"a\"\n"), - ( - "missing-path", - "version = 1\n[[annotations]]\nSPDX-License-Identifier = \"MIT\"\n", - ), - ( - "empty-path", - "version = 1\n[[annotations]]\npath = []\nSPDX-License-Identifier = \"MIT\"\n", - ), - ( - "bad-precedence", - "version = 1\n[[annotations]]\npath = \"a\"\nprecedence = \"bogus\"\n", - ), - ( - "capital-precedence", - "version = 1\n[[annotations]]\npath = \"a\"\nprecedence = \"Closest\"\n", - ), - ( - "bad-expression", - "version = 1\n[[annotations]]\npath = \"a\"\nSPDX-License-Identifier = \"NOT-A-LICENSE\"\n", - ), - ] { - let mut oob = OutOfBand::default(); - let err = oob - .parse_reuse_toml(Path::new("REUSE.toml"), text) - .expect_err(&format!("{name} must fail")); - let msg = err.to_string(); - assert!( - msg.contains("REUSE.toml"), - "{name}: error names the document: {msg}" - ); - } - let mut oob = OutOfBand::default(); - let err = oob - .parse_dep5("Files: a.bin\nLicense: NOT-A-LICENSE\n") - .expect_err("bad dep5 License must fail"); - assert!(err.to_string().contains(".reuse/dep5"), "{err}"); - } - - #[test] - fn missing_version_fails() { - let mut oob = OutOfBand::default(); - let err = oob - .parse_reuse_toml(Path::new("REUSE.toml"), "[[annotations]]\npath = \"a\"\n") - .expect_err("missing version must fail"); - assert!(err.to_string().contains("REUSE.toml"), "{err}"); - } - - #[test] - fn unknown_keys_are_ignored() { - // The REUSE schema explicitly permits extension keys. - let oob = load_doc( - "version = 1\n[tool.extra]\nnote = 1\n[[annotations]]\npath = \"a\"\n\ - SPDX-License-Identifier = \"MIT\"\nSPDX-FileComment = \"hi\"\n", - ); - assert_eq!( - oob.lookup(&PathBuf::from("a")).unwrap().fallback_licenses, - vec!["MIT".to_string()] - ); - } - - #[test] - fn nested_documents_resolve_root_first() { - let mut oob = load_doc( - "version = 1\n[[annotations]]\npath = \"sub/f.rs\"\nSPDX-License-Identifier = \"MIT\"\n\ - SPDX-FileCopyrightText = \"2026 Root\"\n", - ); - oob.parse_reuse_toml( - Path::new("sub/REUSE.toml"), - "version = 1\n[[annotations]]\npath = \"f.rs\"\nSPDX-License-Identifier = \"Apache-2.0\"\n", - ) - .unwrap(); - // Per-field nearest-outward fallback: the child's license wins, but - // the child says nothing about copyright so the root still supplies it. - let e = oob.lookup(&PathBuf::from("sub/f.rs")).unwrap(); - assert_eq!(e.fallback_licenses, vec!["Apache-2.0".to_string()]); - assert_eq!(e.fallback_copyrights, vec!["2026 Root".to_string()]); - assert_eq!(e.origins.len(), 2); - assert_eq!( - e.origins[0].metadata_path, - PathBuf::from("REUSE.toml"), - "origins read shallowest-document first" - ); - assert_eq!(e.origins[1].metadata_path, PathBuf::from("sub/REUSE.toml")); - } - - #[test] - fn nested_patterns_cannot_escape_their_directory() { - let mut oob = OutOfBand::default(); - oob.parse_reuse_toml( - Path::new("sub/REUSE.toml"), - "version = 1\n[[annotations]]\npath = \"**\"\nprecedence = \"aggregate\"\n\ - SPDX-License-Identifier = \"MIT\"\n", - ) - .unwrap(); - assert!(oob.lookup(&PathBuf::from("sub/a.rs")).is_some()); - assert!( - oob.lookup(&PathBuf::from("other.rs")).is_none(), - "a nested document never covers its parent" - ); - assert!( - oob.lookup(&PathBuf::from("REUSE.toml")).is_none(), - "metadata documents are not covered by nested globs" - ); - } - - #[test] - fn override_barrier_suppresses_deeper_tables() { - let mut oob = load_doc( - "version = 1\n[[annotations]]\npath = \"sub/f.rs\"\nprecedence = \"override\"\n\ - SPDX-License-Identifier = \"MIT\"\nSPDX-FileCopyrightText = \"2026 Root\"\n", - ); - oob.parse_reuse_toml( - Path::new("sub/REUSE.toml"), - "version = 1\n[[annotations]]\npath = \"f.rs\"\nprecedence = \"aggregate\"\n\ - SPDX-License-Identifier = \"Apache-2.0\"\n", - ) - .unwrap(); - let e = oob.lookup(&PathBuf::from("sub/f.rs")).unwrap(); - assert!(e.suppresses_file); - assert_eq!(e.precedence, Precedence::Override); - assert_eq!(e.licenses, vec!["MIT".to_string()]); - assert!( - !e.licenses.contains(&"Apache-2.0".to_string()), - "the deeper aggregate is suppressed by the rootmost override" - ); - } - - #[test] - fn empty_override_still_suppresses() { - // A field-less override table contributes nothing but still - // suppresses file-level info (reference behavior). - let oob = - load_doc("version = 1\n[[annotations]]\npath = \"a.bin\"\nprecedence = \"override\"\n"); - let e = oob.lookup(&PathBuf::from("a.bin")).unwrap(); - assert!(e.suppresses_file); - assert!(e.licenses.is_empty() && e.copyrights.is_empty()); - } - - #[test] - fn reuse_pattern_grammar() { - // `*` never crosses `/`; `**` does; `?[]{} ` are literal; `\` escapes. - let oob = load_doc( - "version = 1\n\ - [[annotations]]\npath = \"*.png\"\nprecedence = \"aggregate\"\nSPDX-License-Identifier = \"MIT\"\n\ - [[annotations]]\npath = \"a?.png\"\nprecedence = \"aggregate\"\nSPDX-License-Identifier = \"Apache-2.0\"\n\ - [[annotations]]\npath = \"deep/**\"\nprecedence = \"aggregate\"\nSPDX-License-Identifier = \"CC0-1.0\"\n", - ); - // Last match wins within the document. - assert_eq!( - oob.lookup(&PathBuf::from("a?.png")).unwrap().licenses, - vec!["Apache-2.0".to_string()], - "`?` is literal: the exact file matches the literal pattern" - ); - // `*.png` must not have matched `a?.png` via wildcard, or last-match - // would still hold — check a file only `*` can match instead. - assert!( - oob.lookup(&PathBuf::from("sub/a.png")).is_none(), - "`*` must not cross `/`" - ); - assert_eq!( - oob.lookup(&PathBuf::from("deep/nest/a.png")) - .unwrap() - .licenses, - vec!["CC0-1.0".to_string()], - "`**` crosses directories" - ); - } - - #[test] - fn reuse_escapes_match_verbatim() { - let mut oob = OutOfBand::default(); - // TOML `"star\\\\*.bin"` is the pattern `star\*.bin`: a literal star. - oob.parse_reuse_toml( - Path::new("REUSE.toml"), - "version = 1\n[[annotations]]\npath = \"star\\\\*.bin\"\nprecedence = \"aggregate\"\n\ - SPDX-License-Identifier = \"MIT\"\n", - ) - .unwrap(); - assert!(oob.lookup(&PathBuf::from("star*.bin")).is_some()); - assert!(oob.lookup(&PathBuf::from("starX.bin")).is_none()); - } - - #[test] - fn braces_and_brackets_are_literal() { - let oob = load_doc( - "version = 1\n[[annotations]]\npath = \"a[0].{png,bin}\"\nprecedence = \"aggregate\"\n\ - SPDX-License-Identifier = \"MIT\"\n", - ); - assert!(oob.lookup(&PathBuf::from("a[0].{png,bin}")).is_some()); - assert!(oob.lookup(&PathBuf::from("a0.png")).is_none()); - } - - #[test] - fn coexistence_with_dep5_fails() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write( - dir.path().join("REUSE.toml"), - "version = 1\n[[annotations]]\npath = \"a\"\nSPDX-License-Identifier = \"MIT\"\n", - ) - .unwrap(); - std::fs::create_dir(dir.path().join(".reuse")).unwrap(); - std::fs::write( - dir.path().join(".reuse/dep5"), - "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n", - ) - .unwrap(); - let err = OutOfBand::load(dir.path()).expect_err("coexistence must fail"); - let msg = err.to_string(); - assert!( - msg.contains("REUSE.toml") && msg.contains(".reuse/dep5"), - "{msg}" - ); - } - - /// The scan's hierarchy as the write probe sees it. - fn load_root(root: &std::path::Path) -> OutOfBand { - OutOfBand::load(root).unwrap() - } - - #[test] - fn write_annotation_is_idempotent() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let cprs = vec!["2026 Acme".to_string()]; - assert_eq!( - write_annotation(root, "logo.png", "CC0-1.0", &cprs, &load_root(root)).unwrap(), - AnnotationWrite::Appended - ); - let first = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); - // Second write of the same path with the same license is a no-op. - assert_eq!( - write_annotation(root, "logo.png", "CC0-1.0", &cprs, &load_root(root)).unwrap(), - AnnotationWrite::Unchanged - ); - let second = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); - assert_eq!(first, second); - assert_eq!(first.matches("path = \"logo.png\"").count(), 1); - assert!(first.starts_with("version = 1")); - } - - #[test] - fn write_annotation_appends_superseding_stanza_for_same_path() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let cprs = vec!["2026 Acme".to_string()]; - write_annotation(root, "logo.png", "CC0-1.0", &cprs, &load_root(root)).unwrap(); - // A new intent for the same exact path appends a superseding stanza; - // the old one is left byte-for-byte (last match wins per REUSE 3.3). - assert_eq!( - write_annotation(root, "logo.png", "CC-BY-4.0", &cprs, &load_root(root)).unwrap(), - AnnotationWrite::Appended - ); - let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); - assert_eq!(text.matches("path = \"logo.png\"").count(), 2); - assert!(text.contains("SPDX-License-Identifier = \"CC0-1.0\"")); - assert!(text.contains("SPDX-License-Identifier = \"CC-BY-4.0\"")); - - // The appended stanza wins through lookup (a `closest` table surfaces - // as the fallback until file-level info exists) … - let mut oob = OutOfBand::default(); - oob.parse_reuse_toml(Path::new("REUSE.toml"), &text) - .unwrap(); - assert_eq!( - oob.lookup(&PathBuf::from("logo.png")) - .unwrap() - .fallback_licenses, - vec!["CC-BY-4.0".to_string()] - ); - // … and the rerun converges to a no-op. - assert_eq!( - write_annotation(root, "logo.png", "CC-BY-4.0", &cprs, &load_root(root)).unwrap(), - AnnotationWrite::Unchanged - ); - } - - #[test] - fn write_annotation_refuses_malformed_document() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::write(root.join("REUSE.toml"), "[[annotations\npath = \n").unwrap(); - let before = std::fs::read(root.join("REUSE.toml")).unwrap(); - // The probe hierarchy is empty (the scan would already have failed on - // this document); the re-parse gate still refuses to append to it. - let err = write_annotation(root, "logo.png", "CC0-1.0", &[], &OutOfBand::default()) - .expect_err("malformed REUSE.toml must fail closed"); - assert!(err.to_string().contains("re-parse"), "{err}"); - // The broken document is left untouched — nothing appended. - assert_eq!(std::fs::read(root.join("REUSE.toml")).unwrap(), before); - } - - #[test] - fn write_annotation_appends_override_for_glob() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::write( - root.join("REUSE.toml"), - "version = 1\n\n[[annotations]]\npath = \"*.png\"\n\ - SPDX-License-Identifier = \"MIT\"\n", - ) - .unwrap(); - // A glob covers the file; we cannot edit it without affecting siblings, so we - // append a more-specific exact-path block. Last match wins. - assert_eq!( - write_annotation(root, "logo.png", "CC-BY-4.0", &[], &load_root(root)).unwrap(), - AnnotationWrite::Appended - ); - let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); - let mut oob = OutOfBand::default(); - oob.parse_reuse_toml(Path::new("REUSE.toml"), &text) - .unwrap(); - // Both tables are `closest` fallbacks without file-level info, so the - // last match governs each file. - assert_eq!( - oob.lookup(&PathBuf::from("logo.png")) - .unwrap() - .fallback_licenses, - vec!["CC-BY-4.0".to_string()], - "exact override must win over the glob" - ); - assert_eq!( - oob.lookup(&PathBuf::from("other.png")) - .unwrap() - .fallback_licenses, - vec!["MIT".to_string()], - "the glob still governs its other files" - ); - } - - #[test] - fn write_annotations_batch_many_files_single_doc_write() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let empty: Vec = vec![]; - let acme = vec!["2026 Acme".to_string()]; - let outcome = write_annotations( - root, - &[ - AnnotationRequest { - rel_path: "a.png", - license: "CC0-1.0", - copyrights: &empty, - }, - AnnotationRequest { - rel_path: "b.png", - license: "MIT", - copyrights: &acme, - }, - ], - &load_root(root), - ) - .unwrap(); - assert_eq!( - outcome.per_request, - vec![AnnotationWrite::Appended, AnnotationWrite::Appended] - ); - // One document touched, one patch carrying both requests. - assert_eq!(outcome.patches.len(), 1); - assert_eq!(outcome.patches[0].doc_rel, PathBuf::from("REUSE.toml")); - assert_eq!(outcome.patches[0].requests, vec![0, 1]); - let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); - assert_eq!(text.matches("[[annotations]]").count(), 2); - // Both resolve, and a rerun over the same batch converges entirely. - let oob = load_root(root); - assert_eq!( - oob.lookup(&PathBuf::from("a.png")) - .unwrap() - .fallback_licenses, - vec!["CC0-1.0".to_string()] - ); - assert_eq!( - oob.lookup(&PathBuf::from("b.png")) - .unwrap() - .fallback_licenses, - vec!["MIT".to_string()] - ); - let rerun = write_annotations( - root, - &[ - AnnotationRequest { - rel_path: "a.png", - license: "CC0-1.0", - copyrights: &empty, - }, - AnnotationRequest { - rel_path: "b.png", - license: "MIT", - copyrights: &acme, - }, - ], - &oob, - ) - .unwrap(); - assert_eq!( - rerun.per_request, - vec![AnnotationWrite::Unchanged, AnnotationWrite::Unchanged] - ); - assert!(rerun.patches.is_empty()); - assert_eq!( - std::fs::read_to_string(root.join("REUSE.toml")).unwrap(), - text - ); - } - - #[test] - fn multiple_copyrights_serialize_as_list() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let cprs = vec!["2026 Acme".to_string(), "2027 Acme".to_string()]; - assert_eq!( - write_annotation(root, "logo.png", "CC0-1.0", &cprs, &load_root(root)).unwrap(), - AnnotationWrite::Appended - ); - let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); - // One string/list field, never repeated keys (repeated keys are - // invalid TOML and would fail the re-parse gate). - assert_eq!(text.matches("SPDX-FileCopyrightText").count(), 1, "{text}"); - assert!( - text.contains("SPDX-FileCopyrightText = [\"2026 Acme\", \"2027 Acme\"]"), - "{text}" - ); - let oob = load_root(root); - assert_eq!( - oob.lookup(&PathBuf::from("logo.png")) - .unwrap() - .fallback_copyrights, - cprs - ); - } - - #[test] - fn glob_metachar_path_is_escaped_to_literal() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - assert_eq!( - write_annotation(root, "a*b.png", "CC0-1.0", &[], &load_root(root)).unwrap(), - AnnotationWrite::Appended - ); - let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); - // Two layers of escaping: TOML decodes `\\` to `\`, leaving the - // REUSE-level `\`-asterisk escape the matcher (and the reference - // tool) reads as a literal star. - assert!(text.contains("path = \"a\\\\*b.png\""), "{text}"); - let oob = load_root(root); - assert!(oob.lookup(&PathBuf::from("a*b.png")).is_some()); - assert!( - oob.lookup(&PathBuf::from("aXb.png")).is_none(), - "escaped path must not act as a glob" - ); - } - - #[test] - fn crlf_document_keeps_crlf_on_append() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::write( - root.join("REUSE.toml"), - "version = 1\r\n\r\n[[annotations]]\r\npath = \"a.png\"\r\n\ - SPDX-License-Identifier = \"MIT\"\r\n", - ) - .unwrap(); - assert_eq!( - write_annotation(root, "b.png", "CC0-1.0", &[], &load_root(root)).unwrap(), - AnnotationWrite::Appended - ); - let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); - assert!(!text.contains('\n') || text.contains("\r\n"), "{text:?}"); - assert!( - text.contains("[[annotations]]\r\npath = \"b.png\"\r\n"), - "{text:?}" - ); - // Still parses: the appended stanza reuses the document convention. - load_root(root) - .lookup(&PathBuf::from("b.png")) - .expect("b.png covered"); - } - - #[test] - fn two_holder_broad_annotation_gets_exact_exception() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let before = "version = 1\n\n[[annotations]]\npath = [\"a.png\", \"b.png\"]\n\ - SPDX-FileCopyrightText = [\"2026 Acme\", \"2027 Acme\"]\n\ - SPDX-License-Identifier = \"MIT\"\n"; - std::fs::write(root.join("REUSE.toml"), before).unwrap(); - // Narrowing a.png must not rewrite the shared stanza: append an exact - // exception carrying both holders. - assert_eq!( - write_annotation( - root, - "a.png", - "CC-BY-4.0", - &["2026 Acme".to_string(), "2027 Acme".to_string()], - &load_root(root), - ) - .unwrap(), - AnnotationWrite::Appended - ); - let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); - assert!( - text.starts_with(before), - "shared stanza byte-identical: {text}" - ); - let oob = load_root(root); - assert_eq!( - oob.lookup(&PathBuf::from("a.png")) - .unwrap() - .fallback_licenses, - vec!["CC-BY-4.0".to_string()] - ); - assert_eq!( - oob.lookup(&PathBuf::from("a.png")) - .unwrap() - .fallback_copyrights, - vec!["2026 Acme".to_string(), "2027 Acme".to_string()] - ); - // The sibling still rides the broad annotation untouched. - assert_eq!( - oob.lookup(&PathBuf::from("b.png")) - .unwrap() - .fallback_licenses, - vec!["MIT".to_string()] - ); - } - - #[test] - fn matching_license_missing_copyright_appends() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::write( - root.join("REUSE.toml"), - "version = 1\n\n[[annotations]]\npath = \"a.png\"\n\ - SPDX-License-Identifier = \"MIT\"\n", - ) - .unwrap(); - // Same license but a new requested notice is a real change, not Unchanged. - assert_eq!( - write_annotation( - root, - "a.png", - "MIT", - &["2026 Acme".to_string()], - &load_root(root), - ) - .unwrap(), - AnnotationWrite::Appended - ); - let oob = load_root(root); - assert_eq!( - oob.lookup(&PathBuf::from("a.png")) - .unwrap() - .fallback_copyrights, - vec!["2026 Acme".to_string()] - ); - } - - #[test] - fn unknown_keys_comments_and_literal_path_survive_append() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let before = "# Hand-authored header comment.\nversion = 1\ncustom = 42\n\n\ - [[annotations]]\npath = 'a.png'\n# inline comment\nunknown-key = true\n\ - SPDX-License-Identifier = \"MIT\"\n"; - std::fs::write(root.join("REUSE.toml"), before).unwrap(); - assert_eq!( - write_annotation(root, "b.png", "CC0-1.0", &[], &load_root(root)).unwrap(), - AnnotationWrite::Appended - ); - let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); - assert!( - text.starts_with(before), - "hand-authored content byte-identical: {text}" - ); - } - - #[test] - fn subdir_closest_routes_to_subdir_doc_with_base_path() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::create_dir(root.join("sub")).unwrap(); - std::fs::write( - root.join("sub/REUSE.toml"), - "version = 1\n\n[[annotations]]\npath = \"*.png\"\n\ - SPDX-License-Identifier = \"MIT\"\n", - ) - .unwrap(); - // The subdir document wins the `closest` fallback for its tree, so the - // exact exception lands there with a base-relative path — a root - // stanza would lose to the nearer document. - let dest = annotation_destination(&load_root(root), "sub/logo.png"); - assert_eq!(dest.doc_rel, PathBuf::from("sub/REUSE.toml")); - assert_eq!(dest.base_path, "logo.png"); - assert!(!dest.use_override); - assert_eq!( - write_annotation(root, "sub/logo.png", "CC-BY-4.0", &[], &load_root(root)).unwrap(), - AnnotationWrite::Appended - ); - assert!( - !root.join("REUSE.toml").exists(), - "nothing appended at the root" - ); - let text = std::fs::read_to_string(root.join("sub/REUSE.toml")).unwrap(); - assert!(text.contains("path = \"logo.png\""), "{text}"); - let oob = load_root(root); - assert_eq!( - oob.lookup(&PathBuf::from("sub/logo.png")) - .unwrap() - .fallback_licenses, - vec!["CC-BY-4.0".to_string()] - ); - } - - #[test] - fn subdir_override_barrier_gets_root_override_stanza() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::create_dir(root.join("sub")).unwrap(); - std::fs::write( - root.join("sub/REUSE.toml"), - "version = 1\n\n[[annotations]]\npath = \"logo.png\"\nprecedence = \"override\"\n\ - SPDX-License-Identifier = \"MIT\"\n", - ) - .unwrap(); - // A nearer `override` barrier can only be superseded from the root: - // root is consulted first, so a root override stanza breaks before the - // subdir document is ever read. - let dest = annotation_destination(&load_root(root), "sub/logo.png"); - assert_eq!(dest.doc_rel, PathBuf::from("REUSE.toml")); - assert!(dest.use_override); - assert_eq!( - write_annotation(root, "sub/logo.png", "CC-BY-4.0", &[], &load_root(root)).unwrap(), - AnnotationWrite::Appended - ); - let oob = load_root(root); - let entry = oob.lookup(&PathBuf::from("sub/logo.png")).unwrap(); - assert_eq!(entry.license().as_deref(), Some("CC-BY-4.0")); - assert!(entry.suppresses_file); - } - - #[test] - fn lookup_is_last_match() { - let oob = load_doc( - "version = 1\n[[annotations]]\npath = \"*.png\"\nSPDX-License-Identifier = \"MIT\"\n\ - [[annotations]]\npath = \"logo.png\"\nSPDX-License-Identifier = \"CC-BY-4.0\"\n", - ); - assert_eq!( - oob.lookup(&PathBuf::from("logo.png")) - .unwrap() - .fallback_licenses, - vec!["CC-BY-4.0".to_string()] - ); - } -} -// REUSE-IgnoreEnd diff --git a/src/reuse/oob/lookup.rs b/src/reuse/oob/lookup.rs new file mode 100644 index 0000000..8d3a3d6 --- /dev/null +++ b/src/reuse/oob/lookup.rs @@ -0,0 +1,189 @@ +//! Lookup resolution for out-of-band REUSE metadata: per-path coverage with +//! root-first hierarchy, `override` barriers, and `closest` fallbacks, +//! mirroring the reference REUSE tool (FR-003a). + +use super::*; + +use crate::domain::{MetadataOrigin, OobSource, OutOfBandEntry}; + +impl OutOfBand { + /// Out-of-band coverage for a repo-relative path, if any. + /// + /// Each document contributes exclusively its last matching table; tables + /// are consulted root-first and consultation stops after the rootmost + /// `override` table (mirroring the reference tool). The returned entry + /// carries unconditional (`aggregate`/barrier) values, per-field + /// `closest` fallbacks, and every contributing table's provenance. + pub fn lookup(&self, rel_path: &Path) -> Option { + let rel_str = rel_path.to_string_lossy().replace('\\', "/"); + // Per-document last match, root-first, stopping after an override. + let mut consulted: Vec<(&ReuseDoc, &OobTable)> = Vec::new(); + for doc in &self.docs { + let Some(remainder) = under_base(&rel_str, &doc.base) else { + continue; + }; + if let Some(table) = doc + .tables + .iter() + .rev() + .find(|t| matches_any(&t.matchers, remainder)) + { + let is_override = table.precedence == Precedence::Override; + consulted.push((doc, table)); + if is_override { + break; + } + } + } + // dep5 paragraphs aggregate; the last matching paragraph wins (as in + // the reference tool), contributing alongside any REUSE tables. + let dep5_hit = self + .dep5 + .iter() + .rev() + .find(|p| matches_any(&p.matchers, &rel_str)); + if consulted.is_empty() && dep5_hit.is_none() { + return None; + } + let barrier = consulted + .iter() + .any(|(_, t)| t.precedence == Precedence::Override); + let mut licenses: Vec = Vec::new(); + let mut copyrights: Vec = Vec::new(); + let mut origins: Vec = Vec::new(); + let mut primary_source = OobSource::ReuseToml; + let mut has_aggregate = false; + // Unconditional contributors: the barrier table first (if any), then + // every consulted `aggregate` table shallowest-first, then dep5 — + // matching the reference tool's override-before-aggregate order. + let mut unconditional: Vec<(&ReuseDoc, &OobTable)> = Vec::new(); + if barrier { + let (doc, table) = consulted + .last() + .expect("barrier implies a consulted override table"); + debug_assert_eq!(table.precedence, Precedence::Override); + unconditional.push((*doc, *table)); + } + for (doc, table) in &consulted { + if table.precedence == Precedence::Aggregate { + has_aggregate = true; + unconditional.push((*doc, *table)); + } + } + for (doc, table) in unconditional { + push_unique(&mut licenses, table.licenses.iter().cloned()); + push_unique(&mut copyrights, table.copyrights.iter().cloned()); + origins.push(MetadataOrigin { + metadata_path: doc.rel.clone(), + table_index: table.index, + precedence: table.precedence, + licenses: table.licenses.clone(), + copyrights: table.copyrights.clone(), + }); + } + if let Some(para) = dep5_hit { + has_aggregate = true; + if origins.is_empty() { + primary_source = OobSource::Dep5; + } + push_unique(&mut licenses, para.licenses.iter().cloned()); + push_unique(&mut copyrights, para.copyrights.iter().cloned()); + origins.push(MetadataOrigin { + metadata_path: PathBuf::from(".reuse/dep5"), + table_index: para.index, + precedence: Precedence::Aggregate, + licenses: para.licenses.clone(), + copyrights: para.copyrights.clone(), + }); + } + // Per-field nearest-outward `closest` fallback (deepest consulted + // table wins each field independently). + let mut fallback_licenses: Vec = Vec::new(); + let mut fallback_copyrights: Vec = Vec::new(); + let mut fallback_lic_origin: Option = None; + let mut fallback_cpr_origin: Option = None; + for (doc, table) in consulted.iter().rev() { + if table.precedence != Precedence::Closest { + continue; + } + if fallback_licenses.is_empty() && !table.licenses.is_empty() { + fallback_licenses = table.licenses.clone(); + fallback_lic_origin = Some(MetadataOrigin { + metadata_path: doc.rel.clone(), + table_index: table.index, + precedence: table.precedence, + licenses: table.licenses.clone(), + copyrights: table.copyrights.clone(), + }); + } + if fallback_copyrights.is_empty() && !table.copyrights.is_empty() { + fallback_copyrights = table.copyrights.clone(); + fallback_cpr_origin = Some(MetadataOrigin { + metadata_path: doc.rel.clone(), + table_index: table.index, + precedence: table.precedence, + licenses: table.licenses.clone(), + copyrights: table.copyrights.clone(), + }); + } + } + if fallback_lic_origin + .as_ref() + .is_some_and(|o| Some(o) != fallback_cpr_origin.as_ref()) + { + origins.push(fallback_lic_origin.expect("checked")); + } + if let Some(o) = fallback_cpr_origin { + origins.push(o); + } + if licenses.is_empty() + && copyrights.is_empty() + && fallback_licenses.is_empty() + && fallback_copyrights.is_empty() + { + // Tables matched but none carries any licensing information — an + // override barrier still suppresses the file (reference behavior), + // otherwise there is no coverage at all. + if !barrier { + return None; + } + } + // Origins read shallowest-document first (dep5 is root-level). + origins.sort_by_key(|o| (o.metadata_path.clone(), o.table_index)); + let precedence = if barrier { + Precedence::Override + } else if has_aggregate { + Precedence::Aggregate + } else { + Precedence::Closest + }; + Some(OutOfBandEntry { + source: primary_source, + licenses, + copyrights, + fallback_licenses, + fallback_copyrights, + suppresses_file: barrier, + precedence, + origins, + }) + } + + pub fn is_empty(&self) -> bool { + self.docs.is_empty() && self.dep5.is_empty() + } + + /// Whether `.reuse/dep5` exists in the loaded snapshot (used to refuse + /// writes that would create a mutually-exclusive `REUSE.toml` next to it). + pub fn has_dep5(&self) -> bool { + self.dep5_present + } +} + +fn push_unique(target: &mut Vec, iter: impl Iterator) { + for item in iter { + if !target.contains(&item) { + target.push(item); + } + } +} diff --git a/src/reuse/oob/mod.rs b/src/reuse/oob/mod.rs new file mode 100644 index 0000000..d2a263b --- /dev/null +++ b/src/reuse/oob/mod.rs @@ -0,0 +1,152 @@ +//! Out-of-band REUSE metadata: read `REUSE.toml` (current spec) and `.reuse/dep5` +//! (legacy) for detection, and write `REUSE.toml` annotations for non-annotatable +//! files (FR-015, FR-003a, research §7). +//! +//! `REUSE.toml` files are discovered at every directory depth through the same +//! content snapshot the scan evaluates, so staged checks observe staged +//! metadata. Hierarchy resolution mirrors the reference REUSE tool: documents +//! are consulted root-first and stop after the rootmost `override` table; +//! `aggregate` tables always contribute; `closest` tables are a per-field +//! fallback. Every contributing table keeps its provenance in +//! [`crate::domain::MetadataOrigin`]. + +use std::path::{Path, PathBuf}; + +use globset::GlobMatcher; +use serde::Deserialize; + +use crate::domain::Precedence; + +mod lookup; +mod parse; +mod write; + +pub use write::{ + AnnotationBatchOutcome, AnnotationDestination, AnnotationRequest, AnnotationWrite, DocPatch, + annotation_destination, write_annotation, write_annotations, +}; + +#[cfg(test)] +mod tests; + +#[derive(Debug, Default)] +pub struct OutOfBand { + /// Parsed `REUSE.toml` documents, shallowest directory first. + docs: Vec, + /// Parsed `.reuse/dep5` paragraphs in document order. + dep5: Vec, + /// Whether `.reuse/dep5` exists in the snapshot (even when it carries no + /// paragraphs — mere coexistence with any `REUSE.toml` is an error). + dep5_present: bool, +} + +/// One parsed `REUSE.toml` document plus where it lives. +#[derive(Debug)] +struct ReuseDoc { + /// Repo-relative path of the document (`REUSE.toml`, `sub/REUSE.toml`). + rel: PathBuf, + /// Directory containing the document (empty for the project root); + /// annotation paths are relative to this base and can never escape it. + base: PathBuf, + tables: Vec, +} + +/// One validated `[[annotations]]` table with its matchers precompiled. +#[derive(Debug)] +struct OobTable { + index: usize, + matchers: Vec, + licenses: Vec, + copyrights: Vec, + precedence: Precedence, +} + +/// One parsed `.reuse/dep5` paragraph (always `aggregate` per REUSE 3.3 +/// §"Order of precedence": dep5 licensing adds to file-level licensing). +#[derive(Debug)] +struct Dep5Para { + index: usize, + matchers: Vec, + licenses: Vec, + copyrights: Vec, +} + +/// A path matcher: either a compiled glob or a literal fallback for patterns +/// the glob compiler rejects (never a silent drop). +#[derive(Debug)] +enum ReuseMatcher { + Glob(GlobMatcher), + Literal(String), +} + +impl ReuseMatcher { + fn is_match(&self, path: &str) -> bool { + match self { + ReuseMatcher::Glob(g) => g.is_match(path), + ReuseMatcher::Literal(l) => l == path, + } + } +} + +#[derive(Debug, Deserialize)] +struct ReuseToml { + version: u32, + #[serde(default)] + annotations: Vec, +} + +#[derive(Debug, Deserialize)] +struct ReuseAnnotation { + path: StringList, + #[serde(rename = "SPDX-License-Identifier", default)] + license: StringList, + #[serde(rename = "SPDX-FileCopyrightText", default)] + copyright: StringList, + /// REUSE 3.3 `precedence`: exactly `closest` (default) | `aggregate` | + /// `override`. Anything else is a malformed document, not a silent default. + precedence: Option, +} + +/// A TOML string-or-list field (`path`, licensing, copyright). +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum StringList { + One(String), + Many(Vec), +} + +impl Default for StringList { + fn default() -> Self { + StringList::Many(Vec::new()) + } +} + +impl StringList { + fn into_vec(self) -> Vec { + match self { + StringList::One(s) => vec![s], + StringList::Many(v) => v, + } + } +} + +/// Display a snapshot-relative path with forward slashes. +fn display_rel(rel: &Path) -> String { + rel.to_string_lossy().replace('\\', "/") +} +/// The portion of repo-relative `path` under document base `base`, or `None` +/// when the file is not inside that directory. Containment holds by +/// construction: a pattern can never match outside its own document's tree. +fn under_base<'a>(path: &'a str, base: &Path) -> Option<&'a str> { + let base_str = display_rel(base); + if base_str.is_empty() { + return Some(path); + } + path.strip_prefix(base_str.as_str()) + .and_then(|rest| rest.strip_prefix('/')) + .filter(|rest| !rest.is_empty()) +} + +fn matches_any(matchers: &[ReuseMatcher], path: &str) -> bool { + matchers.iter().any(|m| m.is_match(path)) +} diff --git a/src/reuse/oob/parse.rs b/src/reuse/oob/parse.rs new file mode 100644 index 0000000..b85e4e0 --- /dev/null +++ b/src/reuse/oob/parse.rs @@ -0,0 +1,361 @@ +//! Parsing for out-of-band REUSE metadata: `REUSE.toml` documents at every +//! depth plus legacy `.reuse/dep5` paragraphs, loaded through the same +//! content snapshot the scan evaluates (FR-003a, research §7). + +use super::*; + +use crate::walk::Snapshot; +use globset::GlobBuilder; + +impl OutOfBand { + /// Load `REUSE.toml` documents at every depth plus `.reuse/dep5` from the + /// working tree at `root`. Fallible: malformed metadata is an error with + /// document path and location, never silent absence (F08, F09, F13). + pub fn load(root: &Path) -> crate::error::Result { + Self::load_snapshot(&Snapshot::Worktree { + root: root.to_path_buf(), + }) + } + + /// Load metadata through a content snapshot (staged checks observe staged + /// metadata, never the working copy). + /// + /// Read failures and non-UTF-8 metadata are errors, never silent absence + /// (F04, F13). Malformed documents, a `version` other than 1, a missing + /// `path`, an invalid `precedence`, an invalid license expression, and + /// `REUSE.toml`+`.reuse/dep5` coexistence all fail here — before any + /// write — carrying the document path and parse location. + pub fn load_snapshot(snapshot: &Snapshot) -> crate::error::Result { + use crate::error::LicetError; + let mut oob = OutOfBand::default(); + for rel in discover_doc_paths(snapshot)? { + let bytes = snapshot.read(&rel)?.ok_or_else(|| { + LicetError::Config(format!( + "REUSE metadata {} disappeared during the scan", + rel.display() + )) + })?; + let text = String::from_utf8(bytes).map_err(|e| { + LicetError::Config(format!( + "REUSE metadata {} is not valid UTF-8: {e}", + rel.display() + )) + })?; + oob.parse_reuse_toml(&rel, &text)?; + } + let dep5_rel = Path::new(".reuse/dep5"); + if let Some(bytes) = snapshot.read(dep5_rel)? { + oob.dep5_present = true; + let text = String::from_utf8(bytes).map_err(|e| { + LicetError::Config(format!( + "REUSE metadata .reuse/dep5 is not valid UTF-8: {e}" + )) + })?; + oob.parse_dep5(&text)?; + } + if oob.dep5_present && !oob.docs.is_empty() { + return Err(LicetError::Config(format!( + "found both '{}' and '.reuse/dep5': REUSE.toml and DEP5 are mutually \ + exclusive, you cannot keep both simultaneously", + display_rel(&oob.docs[0].rel) + ))); + } + Ok(oob) + } + /// Parse one `REUSE.toml` document: structural TOML errors, a `version` + /// other than 1, a missing/empty `path`, an invalid `precedence`, and an + /// invalid license expression all fail with document path and table index. + /// Unknown keys and tables are preserved-ignored per the REUSE schema's + /// extension allowance. + pub(crate) fn parse_reuse_toml(&mut self, rel: &Path, text: &str) -> crate::error::Result<()> { + use crate::error::LicetError; + let loc = display_rel(rel); + let parsed: ReuseToml = toml::from_str(text) + .map_err(|e| LicetError::Config(format!("{loc}: malformed REUSE.toml: {e}")))?; + if parsed.version != 1 { + return Err(LicetError::Config(format!( + "{loc}: unsupported REUSE.toml version {} (this tool implements version 1)", + parsed.version + ))); + } + let base = rel.parent().map(Path::to_path_buf).unwrap_or_default(); + let mut tables = Vec::with_capacity(parsed.annotations.len()); + for (index, ann) in parsed.annotations.into_iter().enumerate() { + let tag = format!("{loc} table [[annotations]] #{index}"); + let paths = ann.path.into_vec(); + if paths.is_empty() { + return Err(LicetError::Config(format!( + "{tag}: 'path' must not be empty" + ))); + } + let precedence = match ann.precedence.as_deref() { + None => Precedence::Closest, + Some("closest") => Precedence::Closest, + Some("aggregate") => Precedence::Aggregate, + Some("override") => Precedence::Override, + Some(other) => { + return Err(LicetError::Config(format!( + "{tag}: invalid precedence {other:?}: must be one of \ + 'closest', 'aggregate', 'override'" + ))); + } + }; + let mut licenses = Vec::new(); + for raw in ann.license.into_vec() { + crate::spdx::validate_expression(&raw).map_err(|reason| { + LicetError::Config(format!("{tag}: invalid SPDX-License-Identifier: {reason}")) + })?; + licenses.push(raw.trim().to_string()); + } + let copyrights = ann + .copyright + .into_vec() + .into_iter() + .map(|c| c.trim().to_string()) + .filter(|c| !c.is_empty()) + .collect(); + let matchers = paths.iter().map(|p| compile_reuse_pattern(p)).collect(); + tables.push(OobTable { + index, + matchers, + licenses, + copyrights, + precedence, + }); + } + self.docs.push(ReuseDoc { + rel: rel.to_path_buf(), + base, + tables, + }); + // Keep documents ordered shallowest-first so lookup consults them + // root-first (mirrors the reference tool). + self.docs.sort_by_key(|d| d.base.components().count()); + Ok(()) + } + /// Debian dep5 (`.reuse/dep5`) parser: `Files:`/`Copyright:`/`License:` + /// paragraphs (field names ASCII-case-insensitive) with continuation-line + /// unfolding — a line starting with whitespace continues the current + /// field, and a lone `.` is a blank. Unknown fields are preserved-ignored + /// without being interpreted. An invalid `License:` expression fails like + /// any other malformed metadata. + pub(crate) fn parse_dep5(&mut self, text: &str) -> crate::error::Result<()> { + use crate::error::LicetError; + #[derive(Default)] + struct Para { + files: String, + copyright: Vec, + license: String, + field: Option, + } + #[derive(Clone, Copy, PartialEq, Eq)] + enum Dep5Field { + Files, + Copyright, + License, + Other, + } + let mut paras: Vec = vec![Para::default()]; + for line in text.lines() { + if line.trim().is_empty() { + paras.push(Para::default()); + continue; + } + let para = paras.last_mut().expect("paragraphs never empty"); + if line.starts_with(' ') || line.starts_with('\t') { + // Continuation of the current field (a lone `.` is blank). + let cont = line.trim(); + if cont.is_empty() || cont == "." { + continue; + } + match para.field { + Some(Dep5Field::Files) => { + para.files.push(' '); + para.files.push_str(cont); + } + Some(Dep5Field::Copyright) => para.copyright.push(cont.to_string()), + Some(Dep5Field::License) => { + para.license.push(' '); + para.license.push_str(cont); + } + Some(Dep5Field::Other) | None => {} + } + continue; + } + if cont_is_dot_only(line) { + continue; + } + let (name, value) = match line.split_once(':') { + Some((n, v)) => (n.trim(), v.trim()), + None => continue, + }; + if name.eq_ignore_ascii_case("Files") { + para.field = Some(Dep5Field::Files); + if !para.files.is_empty() { + para.files.push(' '); + } + para.files.push_str(value); + } else if name.eq_ignore_ascii_case("Copyright") { + para.field = Some(Dep5Field::Copyright); + if !value.is_empty() { + para.copyright.push(value.to_string()); + } + } else if name.eq_ignore_ascii_case("License") { + para.field = Some(Dep5Field::License); + if !para.license.is_empty() { + para.license.push(' '); + } + para.license.push_str(value); + } else { + para.field = Some(Dep5Field::Other); + } + } + for (index, para) in paras.into_iter().enumerate() { + if para.files.trim().is_empty() { + continue; + } + let license = para.license.trim(); + let mut licenses = Vec::new(); + if !license.is_empty() { + crate::spdx::validate_expression(license).map_err(|reason| { + LicetError::Config(format!( + ".reuse/dep5 paragraph #{index}: invalid License: {reason}" + )) + })?; + licenses.push(license.to_string()); + } + let matchers = para + .files + .split_whitespace() + .map(compile_dep5_pattern) + .collect(); + self.dep5.push(Dep5Para { + index, + matchers, + licenses, + copyrights: para.copyright, + }); + } + Ok(()) + } +} + +/// A lone `.` line outside a field continuation is blank padding, not content. +fn cont_is_dot_only(line: &str) -> bool { + line.trim() == "." +} + +/// Compile one `REUSE.toml` path pattern with the REUSE 3.3 grammar: `*` +/// never crosses `/`, `**` (and `**/`) does, only `\`-asterisk and +/// `\`-backslash escapes are special, and `?`, brackets, and braces are +/// literal. Everything else is passed through to globset with `/` as the +/// separator, so a pattern can only ever match inside its document's tree. +fn compile_reuse_pattern(pattern: &str) -> ReuseMatcher { + let mut out = String::with_capacity(pattern.len()); + let mut chars = pattern.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\\' => match chars.next() { + // `\` followed by any other character is that character + // verbatim (spec §REUSE.toml); escape it for globset. + Some(next) => { + out.push('\\'); + out.push(next); + } + None => out.push_str("[\\\\]"), + }, + '*' => { + let mut run = 1; + while chars.peek() == Some(&'*') { + chars.next(); + run += 1; + } + out.push_str(if run >= 2 { "**" } else { "*" }); + } + '?' | '[' | ']' | '{' | '}' => { + out.push('\\'); + out.push(c); + } + _ => out.push(c), + } + } + let full = if out.is_empty() { + "**".to_string() + } else { + out + }; + match GlobBuilder::new(&full).literal_separator(true).build() { + Ok(g) => ReuseMatcher::Glob(g.compile_matcher()), + Err(_) => ReuseMatcher::Literal(pattern.to_string()), + } +} + +/// Compile one `.reuse/dep5` `Files:` pattern: shell-style wildcards where +/// `*` crosses `/` (verified against the reference tool), unlike REUSE.toml. +fn compile_dep5_pattern(pattern: &str) -> ReuseMatcher { + match GlobBuilder::new(pattern).literal_separator(false).build() { + Ok(g) => ReuseMatcher::Glob(g.compile_matcher()), + Err(_) => ReuseMatcher::Literal(pattern.to_string()), + } +} + +/// Repo-relative paths of every `REUSE.toml` document visible in `snapshot`: +/// tracked-or-present files at any depth that are not VCS-ignored. Index +/// snapshots observe tracked entries (tracked files are never VCS-ignored); +/// worktree snapshots walk the filesystem honoring ignore rules, never +/// following symlinks. +fn discover_doc_paths(snapshot: &Snapshot) -> crate::error::Result> { + match snapshot { + Snapshot::Index(index) => { + let mut paths: Vec = index + .entries + .keys() + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n == "REUSE.toml") + }) + .cloned() + .collect(); + paths.sort(); + Ok(paths) + } + Snapshot::Worktree { root } => { + use ignore::{WalkBuilder, overrides::OverrideBuilder}; + let mut ob = OverrideBuilder::new(root); + ob.add("!.git/") + .expect("valid built-in skip override for metadata discovery"); + let overrides = ob.build().expect("valid metadata-discovery overrides"); + let walker = WalkBuilder::new(root) + .hidden(false) + .git_ignore(true) + .git_global(true) + .parents(true) + .overrides(overrides) + .follow_links(false) + .build(); + let mut paths = Vec::new(); + for entry in walker { + let entry = match entry { + Ok(e) => e, + Err(e) => { + return Err(crate::error::LicetError::Io(std::io::Error::other( + format!("metadata discovery traversal failed: {e}"), + ))); + } + }; + if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) { + continue; + } + if entry.file_name().to_str().is_none_or(|n| n != "REUSE.toml") { + continue; + } + if let Ok(rel) = entry.path().strip_prefix(root) { + paths.push(rel.to_path_buf()); + } + } + paths.sort(); + Ok(paths) + } + } +} diff --git a/src/reuse/oob/tests.rs b/src/reuse/oob/tests.rs new file mode 100644 index 0000000..0d12bd6 --- /dev/null +++ b/src/reuse/oob/tests.rs @@ -0,0 +1,784 @@ +// REUSE-IgnoreStart — SPDX tags in the tests below are fixtures, not this file's licensing. +use super::*; +use std::path::PathBuf; + +use crate::domain::OobSource; + +fn load_doc(text: &str) -> OutOfBand { + let mut oob = OutOfBand::default(); + oob.parse_reuse_toml(Path::new("REUSE.toml"), text).unwrap(); + oob +} + +#[test] +fn parses_reuse_toml_glob() { + // A `closest` table is fallback only: its values land in the fallback + // fields, leaving the unconditional ones empty for detection to fill + // from file-level info first. + let oob = load_doc( + "version = 1\n[[annotations]]\npath = \"assets/**\"\n\ + SPDX-License-Identifier = \"CC0-1.0\"\nSPDX-FileCopyrightText = \"2026 Acme\"\n", + ); + let e = oob.lookup(&PathBuf::from("assets/logo.png")).unwrap(); + assert!(e.licenses.is_empty()); + assert_eq!(e.fallback_licenses, vec!["CC0-1.0".to_string()]); + assert_eq!(e.fallback_copyrights, vec!["2026 Acme".to_string()]); + assert_eq!(e.precedence, Precedence::Closest); + assert!(!e.suppresses_file); +} + +#[test] +fn license_array_stays_together() { + // Multiple expressions in one table apply together (aggregate here so + // they are unconditional); the presentation form AND-combines them. + let oob = load_doc( + "version = 1\n[[annotations]]\npath = \"a.bin\"\nprecedence = \"aggregate\"\n\ + SPDX-License-Identifier = [\"MIT\", \"Apache-2.0\"]\n", + ); + let e = oob.lookup(&PathBuf::from("a.bin")).unwrap(); + assert_eq!( + e.licenses, + vec!["MIT".to_string(), "Apache-2.0".to_string()] + ); + assert_eq!(e.license().as_deref(), Some("MIT AND Apache-2.0")); +} + +#[test] +fn parses_dep5() { + let mut oob = OutOfBand::default(); + oob.parse_dep5("Files: img/*\nCopyright: 2026 Acme\nLicense: MIT\n") + .unwrap(); + let e = oob.lookup(&PathBuf::from("img/x.jpg")).unwrap(); + assert_eq!(e.licenses, vec!["MIT".to_string()]); + assert_eq!(e.source, OobSource::Dep5); + assert_eq!(e.precedence, Precedence::Aggregate); +} + +#[test] +fn dep5_tolerates_cr_terminated_input() { + // dep5 files in the wild use CRLF or bare-CR endings; every value + // path trims, so carriage returns never leak into parsed values. + let mut oob = OutOfBand::default(); + oob.parse_dep5("Files: a.bin\r\nLicense: MIT\r").unwrap(); + let e = oob.lookup(&PathBuf::from("a.bin")).unwrap(); + assert_eq!(e.licenses, vec!["MIT".to_string()]); +} + +#[test] +fn dep5_star_crosses_directories() { + // Unlike REUSE.toml, dep5 `*` matches across `/` (reference behavior). + let mut oob = OutOfBand::default(); + oob.parse_dep5("Files: *.bin\nLicense: MIT\n").unwrap(); + assert!(oob.lookup(&PathBuf::from("sub/b.bin")).is_some()); +} + +#[test] +fn dep5_continuation_lines_unfold() { + let mut oob = OutOfBand::default(); + oob.parse_dep5( + "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\ + \n\ + Files: a.bin\n sub/b.bin\n\ + Copyright: 2026 A\n 2026 B\n .\n\ + License: MIT\n", + ) + .unwrap(); + let e = oob.lookup(&PathBuf::from("sub/b.bin")).unwrap(); + assert_eq!(e.licenses, vec!["MIT".to_string()]); + assert_eq!( + e.copyrights, + vec!["2026 A".to_string(), "2026 B".to_string()] + ); +} + +#[test] +fn dep5_last_paragraph_wins() { + // Two paragraphs covering the same file: the last one governs, exactly + // as the reference tool reports. + let mut oob = OutOfBand::default(); + oob.parse_dep5( + "Files: a.bin\nCopyright: 2026 A\nLicense: MIT\n\nFiles: a.bin\nCopyright: 2026 B\nLicense: Apache-2.0\n", + ) + .unwrap(); + let e = oob.lookup(&PathBuf::from("a.bin")).unwrap(); + assert_eq!(e.licenses, vec!["Apache-2.0".to_string()]); + assert_eq!(e.copyrights, vec!["2026 B".to_string()]); + assert_eq!(e.origins.len(), 1); + assert_eq!(e.origins[0].table_index, 1); +} + +#[test] +fn dep5_field_names_are_case_insensitive() { + let mut oob = OutOfBand::default(); + oob.parse_dep5("files: a.bin\ncopyright: 2026 A\nlicense: MIT\n") + .unwrap(); + let e = oob.lookup(&PathBuf::from("a.bin")).unwrap(); + assert_eq!(e.licenses, vec!["MIT".to_string()]); +} + +#[test] +fn no_match_is_none() { + let oob = OutOfBand::default(); + assert!(oob.lookup(&PathBuf::from("x")).is_none()); +} + +#[test] +fn malformed_documents_fail_with_location() { + for (name, text) in [ + ( + "bad-toml", + "version = 1\n[[annotations]]\npath = \nSPDX-License-Identifier = \"MIT\"\n", + ), + ("version-2", "version = 2\n[[annotations]]\npath = \"a\"\n"), + ( + "missing-path", + "version = 1\n[[annotations]]\nSPDX-License-Identifier = \"MIT\"\n", + ), + ( + "empty-path", + "version = 1\n[[annotations]]\npath = []\nSPDX-License-Identifier = \"MIT\"\n", + ), + ( + "bad-precedence", + "version = 1\n[[annotations]]\npath = \"a\"\nprecedence = \"bogus\"\n", + ), + ( + "capital-precedence", + "version = 1\n[[annotations]]\npath = \"a\"\nprecedence = \"Closest\"\n", + ), + ( + "bad-expression", + "version = 1\n[[annotations]]\npath = \"a\"\nSPDX-License-Identifier = \"NOT-A-LICENSE\"\n", + ), + ] { + let mut oob = OutOfBand::default(); + let err = oob + .parse_reuse_toml(Path::new("REUSE.toml"), text) + .expect_err(&format!("{name} must fail")); + let msg = err.to_string(); + assert!( + msg.contains("REUSE.toml"), + "{name}: error names the document: {msg}" + ); + } + let mut oob = OutOfBand::default(); + let err = oob + .parse_dep5("Files: a.bin\nLicense: NOT-A-LICENSE\n") + .expect_err("bad dep5 License must fail"); + assert!(err.to_string().contains(".reuse/dep5"), "{err}"); +} + +#[test] +fn missing_version_fails() { + let mut oob = OutOfBand::default(); + let err = oob + .parse_reuse_toml(Path::new("REUSE.toml"), "[[annotations]]\npath = \"a\"\n") + .expect_err("missing version must fail"); + assert!(err.to_string().contains("REUSE.toml"), "{err}"); +} + +#[test] +fn unknown_keys_are_ignored() { + // The REUSE schema explicitly permits extension keys. + let oob = load_doc( + "version = 1\n[tool.extra]\nnote = 1\n[[annotations]]\npath = \"a\"\n\ + SPDX-License-Identifier = \"MIT\"\nSPDX-FileComment = \"hi\"\n", + ); + assert_eq!( + oob.lookup(&PathBuf::from("a")).unwrap().fallback_licenses, + vec!["MIT".to_string()] + ); +} + +#[test] +fn nested_documents_resolve_root_first() { + let mut oob = load_doc( + "version = 1\n[[annotations]]\npath = \"sub/f.rs\"\nSPDX-License-Identifier = \"MIT\"\n\ + SPDX-FileCopyrightText = \"2026 Root\"\n", + ); + oob.parse_reuse_toml( + Path::new("sub/REUSE.toml"), + "version = 1\n[[annotations]]\npath = \"f.rs\"\nSPDX-License-Identifier = \"Apache-2.0\"\n", + ) + .unwrap(); + // Per-field nearest-outward fallback: the child's license wins, but + // the child says nothing about copyright so the root still supplies it. + let e = oob.lookup(&PathBuf::from("sub/f.rs")).unwrap(); + assert_eq!(e.fallback_licenses, vec!["Apache-2.0".to_string()]); + assert_eq!(e.fallback_copyrights, vec!["2026 Root".to_string()]); + assert_eq!(e.origins.len(), 2); + assert_eq!( + e.origins[0].metadata_path, + PathBuf::from("REUSE.toml"), + "origins read shallowest-document first" + ); + assert_eq!(e.origins[1].metadata_path, PathBuf::from("sub/REUSE.toml")); +} + +#[test] +fn nested_patterns_cannot_escape_their_directory() { + let mut oob = OutOfBand::default(); + oob.parse_reuse_toml( + Path::new("sub/REUSE.toml"), + "version = 1\n[[annotations]]\npath = \"**\"\nprecedence = \"aggregate\"\n\ + SPDX-License-Identifier = \"MIT\"\n", + ) + .unwrap(); + assert!(oob.lookup(&PathBuf::from("sub/a.rs")).is_some()); + assert!( + oob.lookup(&PathBuf::from("other.rs")).is_none(), + "a nested document never covers its parent" + ); + assert!( + oob.lookup(&PathBuf::from("REUSE.toml")).is_none(), + "metadata documents are not covered by nested globs" + ); +} + +#[test] +fn override_barrier_suppresses_deeper_tables() { + let mut oob = load_doc( + "version = 1\n[[annotations]]\npath = \"sub/f.rs\"\nprecedence = \"override\"\n\ + SPDX-License-Identifier = \"MIT\"\nSPDX-FileCopyrightText = \"2026 Root\"\n", + ); + oob.parse_reuse_toml( + Path::new("sub/REUSE.toml"), + "version = 1\n[[annotations]]\npath = \"f.rs\"\nprecedence = \"aggregate\"\n\ + SPDX-License-Identifier = \"Apache-2.0\"\n", + ) + .unwrap(); + let e = oob.lookup(&PathBuf::from("sub/f.rs")).unwrap(); + assert!(e.suppresses_file); + assert_eq!(e.precedence, Precedence::Override); + assert_eq!(e.licenses, vec!["MIT".to_string()]); + assert!( + !e.licenses.contains(&"Apache-2.0".to_string()), + "the deeper aggregate is suppressed by the rootmost override" + ); +} + +#[test] +fn empty_override_still_suppresses() { + // A field-less override table contributes nothing but still + // suppresses file-level info (reference behavior). + let oob = + load_doc("version = 1\n[[annotations]]\npath = \"a.bin\"\nprecedence = \"override\"\n"); + let e = oob.lookup(&PathBuf::from("a.bin")).unwrap(); + assert!(e.suppresses_file); + assert!(e.licenses.is_empty() && e.copyrights.is_empty()); +} + +#[test] +fn reuse_pattern_grammar() { + // `*` never crosses `/`; `**` does; `?[]{} ` are literal; `\` escapes. + let oob = load_doc( + "version = 1\n\ + [[annotations]]\npath = \"*.png\"\nprecedence = \"aggregate\"\nSPDX-License-Identifier = \"MIT\"\n\ + [[annotations]]\npath = \"a?.png\"\nprecedence = \"aggregate\"\nSPDX-License-Identifier = \"Apache-2.0\"\n\ + [[annotations]]\npath = \"deep/**\"\nprecedence = \"aggregate\"\nSPDX-License-Identifier = \"CC0-1.0\"\n", + ); + // Last match wins within the document. + assert_eq!( + oob.lookup(&PathBuf::from("a?.png")).unwrap().licenses, + vec!["Apache-2.0".to_string()], + "`?` is literal: the exact file matches the literal pattern" + ); + // `*.png` must not have matched `a?.png` via wildcard, or last-match + // would still hold — check a file only `*` can match instead. + assert!( + oob.lookup(&PathBuf::from("sub/a.png")).is_none(), + "`*` must not cross `/`" + ); + assert_eq!( + oob.lookup(&PathBuf::from("deep/nest/a.png")) + .unwrap() + .licenses, + vec!["CC0-1.0".to_string()], + "`**` crosses directories" + ); +} + +#[test] +fn reuse_escapes_match_verbatim() { + let mut oob = OutOfBand::default(); + // TOML `"star\\\\*.bin"` is the pattern `star\*.bin`: a literal star. + oob.parse_reuse_toml( + Path::new("REUSE.toml"), + "version = 1\n[[annotations]]\npath = \"star\\\\*.bin\"\nprecedence = \"aggregate\"\n\ + SPDX-License-Identifier = \"MIT\"\n", + ) + .unwrap(); + assert!(oob.lookup(&PathBuf::from("star*.bin")).is_some()); + assert!(oob.lookup(&PathBuf::from("starX.bin")).is_none()); +} + +#[test] +fn braces_and_brackets_are_literal() { + let oob = load_doc( + "version = 1\n[[annotations]]\npath = \"a[0].{png,bin}\"\nprecedence = \"aggregate\"\n\ + SPDX-License-Identifier = \"MIT\"\n", + ); + assert!(oob.lookup(&PathBuf::from("a[0].{png,bin}")).is_some()); + assert!(oob.lookup(&PathBuf::from("a0.png")).is_none()); +} + +#[test] +fn coexistence_with_dep5_fails() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("REUSE.toml"), + "version = 1\n[[annotations]]\npath = \"a\"\nSPDX-License-Identifier = \"MIT\"\n", + ) + .unwrap(); + std::fs::create_dir(dir.path().join(".reuse")).unwrap(); + std::fs::write( + dir.path().join(".reuse/dep5"), + "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n", + ) + .unwrap(); + let err = OutOfBand::load(dir.path()).expect_err("coexistence must fail"); + let msg = err.to_string(); + assert!( + msg.contains("REUSE.toml") && msg.contains(".reuse/dep5"), + "{msg}" + ); +} + +/// The scan's hierarchy as the write probe sees it. +fn load_root(root: &std::path::Path) -> OutOfBand { + OutOfBand::load(root).unwrap() +} + +#[test] +fn write_annotation_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let cprs = vec!["2026 Acme".to_string()]; + assert_eq!( + write_annotation(root, "logo.png", "CC0-1.0", &cprs, &load_root(root)).unwrap(), + AnnotationWrite::Appended + ); + let first = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); + // Second write of the same path with the same license is a no-op. + assert_eq!( + write_annotation(root, "logo.png", "CC0-1.0", &cprs, &load_root(root)).unwrap(), + AnnotationWrite::Unchanged + ); + let second = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); + assert_eq!(first, second); + assert_eq!(first.matches("path = \"logo.png\"").count(), 1); + assert!(first.starts_with("version = 1")); +} + +#[test] +fn write_annotation_appends_superseding_stanza_for_same_path() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let cprs = vec!["2026 Acme".to_string()]; + write_annotation(root, "logo.png", "CC0-1.0", &cprs, &load_root(root)).unwrap(); + // A new intent for the same exact path appends a superseding stanza; + // the old one is left byte-for-byte (last match wins per REUSE 3.3). + assert_eq!( + write_annotation(root, "logo.png", "CC-BY-4.0", &cprs, &load_root(root)).unwrap(), + AnnotationWrite::Appended + ); + let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); + assert_eq!(text.matches("path = \"logo.png\"").count(), 2); + assert!(text.contains("SPDX-License-Identifier = \"CC0-1.0\"")); + assert!(text.contains("SPDX-License-Identifier = \"CC-BY-4.0\"")); + + // The appended stanza wins through lookup (a `closest` table surfaces + // as the fallback until file-level info exists) … + let mut oob = OutOfBand::default(); + oob.parse_reuse_toml(Path::new("REUSE.toml"), &text) + .unwrap(); + assert_eq!( + oob.lookup(&PathBuf::from("logo.png")) + .unwrap() + .fallback_licenses, + vec!["CC-BY-4.0".to_string()] + ); + // … and the rerun converges to a no-op. + assert_eq!( + write_annotation(root, "logo.png", "CC-BY-4.0", &cprs, &load_root(root)).unwrap(), + AnnotationWrite::Unchanged + ); +} + +#[test] +fn write_annotation_refuses_malformed_document() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("REUSE.toml"), "[[annotations\npath = \n").unwrap(); + let before = std::fs::read(root.join("REUSE.toml")).unwrap(); + // The probe hierarchy is empty (the scan would already have failed on + // this document); the re-parse gate still refuses to append to it. + let err = write_annotation(root, "logo.png", "CC0-1.0", &[], &OutOfBand::default()) + .expect_err("malformed REUSE.toml must fail closed"); + assert!(err.to_string().contains("re-parse"), "{err}"); + // The broken document is left untouched — nothing appended. + assert_eq!(std::fs::read(root.join("REUSE.toml")).unwrap(), before); +} + +#[test] +fn write_annotation_appends_override_for_glob() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("REUSE.toml"), + "version = 1\n\n[[annotations]]\npath = \"*.png\"\n\ + SPDX-License-Identifier = \"MIT\"\n", + ) + .unwrap(); + // A glob covers the file; we cannot edit it without affecting siblings, so we + // append a more-specific exact-path block. Last match wins. + assert_eq!( + write_annotation(root, "logo.png", "CC-BY-4.0", &[], &load_root(root)).unwrap(), + AnnotationWrite::Appended + ); + let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); + let mut oob = OutOfBand::default(); + oob.parse_reuse_toml(Path::new("REUSE.toml"), &text) + .unwrap(); + // Both tables are `closest` fallbacks without file-level info, so the + // last match governs each file. + assert_eq!( + oob.lookup(&PathBuf::from("logo.png")) + .unwrap() + .fallback_licenses, + vec!["CC-BY-4.0".to_string()], + "exact override must win over the glob" + ); + assert_eq!( + oob.lookup(&PathBuf::from("other.png")) + .unwrap() + .fallback_licenses, + vec!["MIT".to_string()], + "the glob still governs its other files" + ); +} + +#[test] +fn write_annotations_batch_many_files_single_doc_write() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let empty: Vec = vec![]; + let acme = vec!["2026 Acme".to_string()]; + let outcome = write_annotations( + root, + &[ + AnnotationRequest { + rel_path: "a.png", + license: "CC0-1.0", + copyrights: &empty, + }, + AnnotationRequest { + rel_path: "b.png", + license: "MIT", + copyrights: &acme, + }, + ], + &load_root(root), + ) + .unwrap(); + assert_eq!( + outcome.per_request, + vec![AnnotationWrite::Appended, AnnotationWrite::Appended] + ); + // One document touched, one patch carrying both requests. + assert_eq!(outcome.patches.len(), 1); + assert_eq!(outcome.patches[0].doc_rel, PathBuf::from("REUSE.toml")); + assert_eq!(outcome.patches[0].requests, vec![0, 1]); + let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); + assert_eq!(text.matches("[[annotations]]").count(), 2); + // Both resolve, and a rerun over the same batch converges entirely. + let oob = load_root(root); + assert_eq!( + oob.lookup(&PathBuf::from("a.png")) + .unwrap() + .fallback_licenses, + vec!["CC0-1.0".to_string()] + ); + assert_eq!( + oob.lookup(&PathBuf::from("b.png")) + .unwrap() + .fallback_licenses, + vec!["MIT".to_string()] + ); + let rerun = write_annotations( + root, + &[ + AnnotationRequest { + rel_path: "a.png", + license: "CC0-1.0", + copyrights: &empty, + }, + AnnotationRequest { + rel_path: "b.png", + license: "MIT", + copyrights: &acme, + }, + ], + &oob, + ) + .unwrap(); + assert_eq!( + rerun.per_request, + vec![AnnotationWrite::Unchanged, AnnotationWrite::Unchanged] + ); + assert!(rerun.patches.is_empty()); + assert_eq!( + std::fs::read_to_string(root.join("REUSE.toml")).unwrap(), + text + ); +} + +#[test] +fn multiple_copyrights_serialize_as_list() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let cprs = vec!["2026 Acme".to_string(), "2027 Acme".to_string()]; + assert_eq!( + write_annotation(root, "logo.png", "CC0-1.0", &cprs, &load_root(root)).unwrap(), + AnnotationWrite::Appended + ); + let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); + // One string/list field, never repeated keys (repeated keys are + // invalid TOML and would fail the re-parse gate). + assert_eq!(text.matches("SPDX-FileCopyrightText").count(), 1, "{text}"); + assert!( + text.contains("SPDX-FileCopyrightText = [\"2026 Acme\", \"2027 Acme\"]"), + "{text}" + ); + let oob = load_root(root); + assert_eq!( + oob.lookup(&PathBuf::from("logo.png")) + .unwrap() + .fallback_copyrights, + cprs + ); +} + +#[test] +fn glob_metachar_path_is_escaped_to_literal() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + assert_eq!( + write_annotation(root, "a*b.png", "CC0-1.0", &[], &load_root(root)).unwrap(), + AnnotationWrite::Appended + ); + let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); + // Two layers of escaping: TOML decodes `\\` to `\`, leaving the + // REUSE-level `\`-asterisk escape the matcher (and the reference + // tool) reads as a literal star. + assert!(text.contains("path = \"a\\\\*b.png\""), "{text}"); + let oob = load_root(root); + assert!(oob.lookup(&PathBuf::from("a*b.png")).is_some()); + assert!( + oob.lookup(&PathBuf::from("aXb.png")).is_none(), + "escaped path must not act as a glob" + ); +} + +#[test] +fn crlf_document_keeps_crlf_on_append() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("REUSE.toml"), + "version = 1\r\n\r\n[[annotations]]\r\npath = \"a.png\"\r\n\ + SPDX-License-Identifier = \"MIT\"\r\n", + ) + .unwrap(); + assert_eq!( + write_annotation(root, "b.png", "CC0-1.0", &[], &load_root(root)).unwrap(), + AnnotationWrite::Appended + ); + let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); + assert!(!text.contains('\n') || text.contains("\r\n"), "{text:?}"); + assert!( + text.contains("[[annotations]]\r\npath = \"b.png\"\r\n"), + "{text:?}" + ); + // Still parses: the appended stanza reuses the document convention. + load_root(root) + .lookup(&PathBuf::from("b.png")) + .expect("b.png covered"); +} + +#[test] +fn two_holder_broad_annotation_gets_exact_exception() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let before = "version = 1\n\n[[annotations]]\npath = [\"a.png\", \"b.png\"]\n\ + SPDX-FileCopyrightText = [\"2026 Acme\", \"2027 Acme\"]\n\ + SPDX-License-Identifier = \"MIT\"\n"; + std::fs::write(root.join("REUSE.toml"), before).unwrap(); + // Narrowing a.png must not rewrite the shared stanza: append an exact + // exception carrying both holders. + assert_eq!( + write_annotation( + root, + "a.png", + "CC-BY-4.0", + &["2026 Acme".to_string(), "2027 Acme".to_string()], + &load_root(root), + ) + .unwrap(), + AnnotationWrite::Appended + ); + let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); + assert!( + text.starts_with(before), + "shared stanza byte-identical: {text}" + ); + let oob = load_root(root); + assert_eq!( + oob.lookup(&PathBuf::from("a.png")) + .unwrap() + .fallback_licenses, + vec!["CC-BY-4.0".to_string()] + ); + assert_eq!( + oob.lookup(&PathBuf::from("a.png")) + .unwrap() + .fallback_copyrights, + vec!["2026 Acme".to_string(), "2027 Acme".to_string()] + ); + // The sibling still rides the broad annotation untouched. + assert_eq!( + oob.lookup(&PathBuf::from("b.png")) + .unwrap() + .fallback_licenses, + vec!["MIT".to_string()] + ); +} + +#[test] +fn matching_license_missing_copyright_appends() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("REUSE.toml"), + "version = 1\n\n[[annotations]]\npath = \"a.png\"\n\ + SPDX-License-Identifier = \"MIT\"\n", + ) + .unwrap(); + // Same license but a new requested notice is a real change, not Unchanged. + assert_eq!( + write_annotation( + root, + "a.png", + "MIT", + &["2026 Acme".to_string()], + &load_root(root), + ) + .unwrap(), + AnnotationWrite::Appended + ); + let oob = load_root(root); + assert_eq!( + oob.lookup(&PathBuf::from("a.png")) + .unwrap() + .fallback_copyrights, + vec!["2026 Acme".to_string()] + ); +} + +#[test] +fn unknown_keys_comments_and_literal_path_survive_append() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let before = "# Hand-authored header comment.\nversion = 1\ncustom = 42\n\n\ + [[annotations]]\npath = 'a.png'\n# inline comment\nunknown-key = true\n\ + SPDX-License-Identifier = \"MIT\"\n"; + std::fs::write(root.join("REUSE.toml"), before).unwrap(); + assert_eq!( + write_annotation(root, "b.png", "CC0-1.0", &[], &load_root(root)).unwrap(), + AnnotationWrite::Appended + ); + let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); + assert!( + text.starts_with(before), + "hand-authored content byte-identical: {text}" + ); +} + +#[test] +fn subdir_closest_routes_to_subdir_doc_with_base_path() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir(root.join("sub")).unwrap(); + std::fs::write( + root.join("sub/REUSE.toml"), + "version = 1\n\n[[annotations]]\npath = \"*.png\"\n\ + SPDX-License-Identifier = \"MIT\"\n", + ) + .unwrap(); + // The subdir document wins the `closest` fallback for its tree, so the + // exact exception lands there with a base-relative path — a root + // stanza would lose to the nearer document. + let dest = annotation_destination(&load_root(root), "sub/logo.png"); + assert_eq!(dest.doc_rel, PathBuf::from("sub/REUSE.toml")); + assert_eq!(dest.base_path, "logo.png"); + assert!(!dest.use_override); + assert_eq!( + write_annotation(root, "sub/logo.png", "CC-BY-4.0", &[], &load_root(root)).unwrap(), + AnnotationWrite::Appended + ); + assert!( + !root.join("REUSE.toml").exists(), + "nothing appended at the root" + ); + let text = std::fs::read_to_string(root.join("sub/REUSE.toml")).unwrap(); + assert!(text.contains("path = \"logo.png\""), "{text}"); + let oob = load_root(root); + assert_eq!( + oob.lookup(&PathBuf::from("sub/logo.png")) + .unwrap() + .fallback_licenses, + vec!["CC-BY-4.0".to_string()] + ); +} + +#[test] +fn subdir_override_barrier_gets_root_override_stanza() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir(root.join("sub")).unwrap(); + std::fs::write( + root.join("sub/REUSE.toml"), + "version = 1\n\n[[annotations]]\npath = \"logo.png\"\nprecedence = \"override\"\n\ + SPDX-License-Identifier = \"MIT\"\n", + ) + .unwrap(); + // A nearer `override` barrier can only be superseded from the root: + // root is consulted first, so a root override stanza breaks before the + // subdir document is ever read. + let dest = annotation_destination(&load_root(root), "sub/logo.png"); + assert_eq!(dest.doc_rel, PathBuf::from("REUSE.toml")); + assert!(dest.use_override); + assert_eq!( + write_annotation(root, "sub/logo.png", "CC-BY-4.0", &[], &load_root(root)).unwrap(), + AnnotationWrite::Appended + ); + let oob = load_root(root); + let entry = oob.lookup(&PathBuf::from("sub/logo.png")).unwrap(); + assert_eq!(entry.license().as_deref(), Some("CC-BY-4.0")); + assert!(entry.suppresses_file); +} + +#[test] +fn lookup_is_last_match() { + let oob = load_doc( + "version = 1\n[[annotations]]\npath = \"*.png\"\nSPDX-License-Identifier = \"MIT\"\n\ + [[annotations]]\npath = \"logo.png\"\nSPDX-License-Identifier = \"CC-BY-4.0\"\n", + ); + assert_eq!( + oob.lookup(&PathBuf::from("logo.png")) + .unwrap() + .fallback_licenses, + vec!["CC-BY-4.0".to_string()] + ); +} +// REUSE-IgnoreEnd diff --git a/src/reuse/oob/write.rs b/src/reuse/oob/write.rs new file mode 100644 index 0000000..701ca18 --- /dev/null +++ b/src/reuse/oob/write.rs @@ -0,0 +1,378 @@ +//! Batch writing for out-of-band REUSE metadata: routing annotation requests +//! to their winning documents and appending exact-path stanzas that reuse +//! their document'"'"'s own newline convention (FR-015). + +use super::*; + +/// Outcome of [`write_annotation`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AnnotationWrite { + /// The file's effective `REUSE.toml` license already matched; nothing written. + Unchanged, + /// A new exact-path annotation was appended — covering a previously + /// uncovered file, overriding a broader glob entry, or superseding an + /// older exact-path entry. Being last, it wins per REUSE 3.3. + Appended, +} + +impl AnnotationWrite { + /// Whether the file was actually rewritten. + pub fn modified(self) -> bool { + !matches!(self, AnnotationWrite::Unchanged) + } +} + +/// One exact-path annotation the caller wants covered by `REUSE.toml`. +pub struct AnnotationRequest<'a> { + pub rel_path: &'a str, + pub license: &'a str, + pub copyrights: &'a [String], +} + +/// Where one annotation patch belongs: the document that wins for the path, +/// the path relative to that document's base, and whether the stanza needs +/// `override` precedence to govern there. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AnnotationDestination { + /// Repo-relative document path (`REUSE.toml`, `sub/REUSE.toml`). + pub doc_rel: PathBuf, + /// Request path relative to the document's base. + pub base_path: String, + /// True when only an `override` stanza governs at the destination: under + /// an `override` barrier, or when an `aggregate` table contributes + /// unconditionally (a default-`closest` stanza would neither break the + /// barrier nor silence the aggregate). + pub use_override: bool, +} + +/// Route an annotation to its winning document (FR-003a). +/// +/// The walk mirrors [`OutOfBand::lookup`] over the already-loaded hierarchy: +/// - no coverage, or `closest` governing → the nearest consulted document +/// (the project root when nothing consults), default precedence — nearest +/// wins the per-field `closest` fallback; +/// - `override` or `aggregate` governing → the project root with `override` +/// precedence — root is consulted first, so the appended stanza breaks +/// before every deeper table and supersedes root tables by last match. +pub fn annotation_destination(oob: &OutOfBand, rel_path: &str) -> AnnotationDestination { + let mut consulted: Vec<(usize, Precedence)> = Vec::new(); + for (di, doc) in oob.docs.iter().enumerate() { + let Some(remainder) = under_base(rel_path, &doc.base) else { + continue; + }; + if let Some(table) = doc + .tables + .iter() + .rev() + .find(|t| matches_any(&t.matchers, remainder)) + { + let is_override = table.precedence == Precedence::Override; + consulted.push((di, table.precedence)); + if is_override { + break; + } + } + } + let governing = if consulted.iter().any(|(_, p)| *p == Precedence::Override) { + Some(Precedence::Override) + } else if consulted.iter().any(|(_, p)| *p == Precedence::Aggregate) { + Some(Precedence::Aggregate) + } else if consulted.is_empty() { + None + } else { + Some(Precedence::Closest) + }; + match governing { + Some(Precedence::Override) | Some(Precedence::Aggregate) => AnnotationDestination { + doc_rel: PathBuf::from("REUSE.toml"), + base_path: rel_path.to_string(), + use_override: true, + }, + Some(Precedence::Closest) => { + let (di, _) = consulted.last().copied().expect("consulted non-empty"); + let doc = &oob.docs[di]; + AnnotationDestination { + doc_rel: doc.rel.clone(), + base_path: under_base(rel_path, &doc.base) + .unwrap_or(rel_path) + .to_string(), + use_override: false, + } + } + None => AnnotationDestination { + doc_rel: PathBuf::from("REUSE.toml"), + base_path: rel_path.to_string(), + use_override: false, + }, + } +} + +/// Ensure a `REUSE.toml` annotation covers `rel_path` with `license` (FR-015, FR-003a). +/// +/// Single-request form of [`write_annotations`]; `oob` is the scan's loaded +/// hierarchy, used for coverage and destination routing. +pub fn write_annotation( + root: &Path, + rel_path: &str, + license: &str, + copyrights: &[String], + oob: &OutOfBand, +) -> Result { + let outcomes = write_annotations( + root, + &[AnnotationRequest { + rel_path, + license, + copyrights, + }], + oob, + )?; + Ok(outcomes + .per_request + .into_iter() + .next() + .unwrap_or(AnnotationWrite::Unchanged)) +} + +/// One patched metadata document: the before/after bytes plus which caller +/// requests its stanzas carry, so the caller files one write record per +/// document with the covered assets attached. +#[derive(Debug, Clone)] +pub struct DocPatch { + pub doc_rel: PathBuf, + pub before: Option>, + pub after: Vec, + pub requests: Vec, +} + +/// Outcome of [`write_annotations`]: per-request dispositions plus the +/// document patches actually committed (one per touched document). +#[derive(Debug, Clone)] +pub struct AnnotationBatchOutcome { + pub per_request: Vec, + pub patches: Vec, +} + +/// Ensure `REUSE.toml` annotations cover every request (FR-015, FR-003a). +/// +/// `oob` is the scan's loaded hierarchy: coverage and destination routing read +/// it, so decisions match what detection sees. Each request whose effective +/// license *and* copyrights already match is left untouched +/// ([`AnnotationWrite::Unchanged`]); every other request gains one appended +/// exact-path stanza ([`AnnotationWrite::Appended`]) in its winning document +/// ([`annotation_destination`]). Creates the project root document with a +/// `version = 1` header if absent; never creates deeper documents. +/// +/// The patch is strictly append-only: existing documents are never reparsed +/// with a lossy line scan and never reformatted, so comments, ordering, and +/// unrelated stanzas survive byte-for-byte, and a repeated run converges. +/// Requests group by destination document, so each document is read, patched, +/// and written exactly once. Every patched document is re-parsed — and every +/// appended coverage verified — before anything is committed. Copyrights +/// serialize as one string/list field, never repeated keys; exact paths escape +/// REUSE glob metacharacters so odd filenames cannot become new patterns; +/// appended stanzas reuse their document's own newline convention. +/// +/// An unreadable or non-UTF-8 document is an error, never silently replaced +/// with fresh content (F04). +pub fn write_annotations( + root: &Path, + requests: &[AnnotationRequest<'_>], + oob: &OutOfBand, +) -> Result { + use crate::reuse::{WriteError, atomic_write, read_expected_for_write}; + use std::collections::BTreeMap; + + let mut per_request = vec![AnnotationWrite::Unchanged; requests.len()]; + // Requests needing a stanza, grouped by destination document so each + // document is read, patched, and written exactly once. + let mut pending: BTreeMap> = BTreeMap::new(); + for (i, req) in requests.iter().enumerate() { + let entry = oob.lookup(Path::new(req.rel_path)); + let covered = entry.as_ref().and_then(|e| { + e.license() + .or_else(|| crate::domain::combine_licenses(&e.fallback_licenses)) + }); + let copyrights_match = entry.as_ref().is_some_and(|e| { + norm_set(effective_copyrights(e)) == norm_set(req.copyrights.to_vec()) + }); + if covered.as_deref() == Some(req.license) && copyrights_match { + continue; + } + let dest = annotation_destination(oob, req.rel_path); + pending + .entry(dest.doc_rel.clone()) + .or_default() + .push((i, dest)); + } + if pending.is_empty() { + return Ok(AnnotationBatchOutcome { + per_request, + patches: Vec::new(), + }); + } + + let mut patches: Vec = Vec::new(); + + for (doc_rel, jobs) in &pending { + let existing_bytes = + read_expected_for_write(&root.join(doc_rel)).map_err(WriteError::for_read_failure)?; + let existing = match &existing_bytes { + Some(bytes) => std::str::from_utf8(bytes).map_err(|e| { + WriteError::for_read_failure(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("existing {} is not valid UTF-8: {e}", doc_rel.display()), + )) + })?, + // Only the project root document may be created; a missing deeper + // document means the hierarchy moved under us — refuse, don't invent. + None if doc_rel == Path::new("REUSE.toml") => "", + None => { + return Err(WriteError::for_read_failure(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!( + "annotated document {} disappeared during the scan", + doc_rel.display() + ), + ))); + } + }; + + let nl = if existing.contains("\r\n") { + "\r\n" + } else { + "\n" + }; + let mut out = existing.to_owned(); + if out.is_empty() { + out.push_str("version = 1"); + out.push_str(nl); + out.push_str(nl); + } else if !out.ends_with('\n') { + out.push_str(nl); + } + for (i, dest) in jobs { + let req = &requests[*i]; + out.push_str("[[annotations]]"); + out.push_str(nl); + out.push_str(&format!( + "path = {}", + toml_string(&escape_reuse_path(&dest.base_path)) + )); + out.push_str(nl); + if dest.use_override { + out.push_str("precedence = \"override\""); + out.push_str(nl); + } + match req.copyrights.len() { + 0 => {} + 1 => { + out.push_str(&format!( + "SPDX-FileCopyrightText = {}", + toml_string(&req.copyrights[0]) + )); + out.push_str(nl); + } + _ => { + let list = req + .copyrights + .iter() + .map(|s| toml_string(s)) + .collect::>() + .join(", "); + out.push_str(&format!("SPDX-FileCopyrightText = [{list}]")); + out.push_str(nl); + } + } + out.push_str(&format!( + "SPDX-License-Identifier = {}", + toml_string(req.license) + )); + out.push_str(nl); + out.push_str(nl); + per_request[*i] = AnnotationWrite::Appended; + } + + // Re-parse the complete proposed document before committing, and + // verify every appended coverage through the same lookup detection + // uses (this also validates the escaping and list serialization). + let mut verify = OutOfBand::default(); + verify.parse_reuse_toml(doc_rel, &out).map_err(|e| { + WriteError::for_read_failure(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("proposed {} failed to re-parse: {e}", doc_rel.display()), + )) + })?; + for (i, _) in jobs { + let req = &requests[*i]; + // Verify through the document's own base, exactly as detection + // will read it back. + let got = verify.lookup(Path::new(req.rel_path)).and_then(|e| { + e.license() + .or_else(|| crate::domain::combine_licenses(&e.fallback_licenses)) + }); + if got.as_deref() != Some(req.license) { + return Err(WriteError::for_read_failure(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "proposed {} does not cover {} with {}", + doc_rel.display(), + req.rel_path, + req.license + ), + ))); + } + } + + atomic_write(root, doc_rel, existing_bytes.as_deref(), out.as_bytes())?; + patches.push(DocPatch { + doc_rel: doc_rel.clone(), + before: existing_bytes.clone(), + after: out.into_bytes(), + requests: jobs.iter().map(|(i, _)| *i).collect(), + }); + } + Ok(AnnotationBatchOutcome { + per_request, + patches, + }) +} + +/// Effective OOB copyrights for a lookup entry, mirroring the license logic: +/// unconditional contributors first, else the `closest` fallback. +fn effective_copyrights(entry: &crate::domain::OutOfBandEntry) -> Vec { + if entry.copyrights.is_empty() { + entry.fallback_copyrights.clone() + } else { + entry.copyrights.clone() + } +} + +/// Order-insensitive comparison form for notice lists. +fn norm_set(mut values: Vec) -> Vec { + values.sort(); + values.dedup(); + values +} + +/// Escape the REUSE glob metacharacters in an exact path so the emitted stanza +/// cannot act as a pattern: a file named `a*b.png` must not start covering +/// its siblings. Only `\` and `*` are escaped: both engines treat `\`-asterisk +/// and `\\`-backslash as escapes, while `?`, brackets, and braces are literal +/// in both grammars and must stay bare (this crate's matcher and the reference +/// tool's pathspec grammar agree on all of this). +fn escape_reuse_path(path: &str) -> String { + let mut out = String::with_capacity(path.len()); + for c in path.chars() { + if matches!(c, '\\' | '*') { + out.push('\\'); + } + out.push(c); + } + out +} + +/// Quote a value for emission into a `REUSE.toml` stanza (write-side escaping only). +fn toml_string(s: &str) -> String { + format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) +} diff --git a/src/walk/mod.rs b/src/walk/mod.rs index 33e6718..baeb299 100644 --- a/src/walk/mod.rs +++ b/src/walk/mod.rs @@ -118,44 +118,36 @@ pub fn prepare( (None, _) => walk_worktree(&root)?, }; let snapshot = Snapshot::Worktree { root: root.clone() }; - let config_text = read_config_text( - &snapshot, - &root, + assemble( + Assembly { + root, + source: crate::domain::ContentSource::Worktree, + snapshot, + universe, + expanded: false, + note: None, + }, config_rel.as_deref(), purpose, allow_missing_config, - )?; - let paths = mark_sorted(&root, &snapshot, &universe)?; - Ok(Prepared { - root, - source: crate::domain::ContentSource::Worktree, - snapshot, - paths, - expanded: false, - expansion_note: None, - config_text, - }) + ) } Selection::Files(files) => { let universe = normalize_file_list(&root, cwd, files)?; let snapshot = Snapshot::Worktree { root: root.clone() }; - let config_text = read_config_text( - &snapshot, - &root, + assemble( + Assembly { + root, + source: crate::domain::ContentSource::Worktree, + snapshot, + universe, + expanded: false, + note: None, + }, config_rel.as_deref(), purpose, allow_missing_config, - )?; - let paths = mark_sorted(&root, &snapshot, &universe)?; - Ok(Prepared { - root, - source: crate::domain::ContentSource::Worktree, - snapshot, - paths, - expanded: false, - expansion_note: None, - config_text, - }) + ) } Selection::Staged => { let repo = repo.ok_or_else(|| { @@ -168,23 +160,19 @@ pub fn prepare( // path set: edits must never spill beyond it, and the index is // never written. let snapshot = Snapshot::Worktree { root: root.clone() }; - let config_text = read_config_text( - &snapshot, - &root, + return assemble( + Assembly { + root, + source: crate::domain::ContentSource::Worktree, + snapshot, + universe: subset, + expanded: false, + note: None, + }, config_rel.as_deref(), Purpose::Policy, allow_missing_config, - )?; - let paths = mark_sorted(&root, &snapshot, &subset)?; - return Ok(Prepared { - root, - source: crate::domain::ContentSource::Worktree, - snapshot, - paths, - expanded: false, - expansion_note: None, - config_text, - }); + ); } // An unresolved merge poisons the whole index snapshot, not just the // evaluated subset. @@ -206,23 +194,19 @@ pub fn prepare( let extra: Vec = config_rel.clone().into_iter().collect(); index.prefetch(&repo.root, &universe, &extra)?; let snapshot = Snapshot::Index(index); - let config_text = read_config_text( - &snapshot, - &root, + assemble( + Assembly { + root, + source: crate::domain::ContentSource::Index, + snapshot, + universe, + expanded, + note, + }, config_rel.as_deref(), purpose, allow_missing_config, - )?; - let paths = mark_sorted(&root, &snapshot, &universe)?; - Ok(Prepared { - root, - source: crate::domain::ContentSource::Index, - snapshot, - paths, - expanded, - expansion_note: note, - config_text, - }) + ) } Selection::Changed(rev) => { let repo = repo.ok_or_else(|| { @@ -240,27 +224,63 @@ pub fn prepare( maybe_expand(&index, subset, &deleted, config_rel.as_deref()) }; let snapshot = Snapshot::Worktree { root: root.clone() }; - let config_text = read_config_text( - &snapshot, - &root, + assemble( + Assembly { + root, + source: crate::domain::ContentSource::Worktree, + snapshot, + universe, + expanded, + note, + }, config_rel.as_deref(), purpose, allow_missing_config, - )?; - let paths = mark_sorted(&root, &snapshot, &universe)?; - Ok(Prepared { - root, - source: crate::domain::ContentSource::Worktree, - snapshot, - paths, - expanded, - expansion_note: note, - config_text, - }) + ) } } } +/// A selection arm's resolved inputs, before the shared assembly tail reads +/// the config and marks REUSE-ignore status. +struct Assembly { + root: PathBuf, + source: crate::domain::ContentSource, + snapshot: Snapshot, + universe: Vec, + expanded: bool, + note: Option, +} + +/// Shared assembly tail for every selection arm: read the config through the +/// same snapshot that supplies file bytes, mark REUSE-ignore status, and pack +/// the evaluation plan. One place, so snapshot/config/marking can never drift +/// between selections. +fn assemble( + a: Assembly, + config_rel: Option<&Path>, + purpose: Purpose, + allow_missing_config: bool, +) -> Result { + let config_text = read_config_text( + &a.snapshot, + &a.root, + config_rel, + purpose, + allow_missing_config, + )?; + let paths = mark_sorted(&a.root, &a.snapshot, &a.universe)?; + Ok(Prepared { + root: a.root, + source: a.source, + snapshot: a.snapshot, + paths, + expanded: a.expanded, + expansion_note: a.note, + config_text, + }) +} + /// Resolve the config argument to a root-relative path when it stays inside the /// root; `None` when it points outside (worktree reads only — an index snapshot /// cannot contain it). From da14ca39d5edc3c2888eb11fa55d46ca47331b9d Mon Sep 17 00:00:00 2001 From: Adam Poulemanos Date: Fri, 11 Sep 2026 09:37:50 -0400 Subject: [PATCH 06/12] fix: reconcile manifest/lock with rebased main, restore serde_json dep Main's manifest lost the serde_json dependency while src (report, add-license, lint) still uses serde_json::json!, so the rebased tree did not compile. Re-add serde_json = "1" (lock resolves to 1.0.150, matching the stale lock entry main carried). Lockfile: apply Deprecation version bumps, prune gix/sha2 subtrees removed from the manifest. Verified: 400 tests pass, clippy -D warnings clean, fmt clean, MSRV 1.89 check ok, 24/24 REUSE differential, offline guard shows no network crates. --- Cargo.lock | 101 +++++++++++++++++++++++++++++------------------------ Cargo.toml | 7 +--- 2 files changed, 57 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4021c9b..bec4196 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -78,9 +78,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "assert_cmd" @@ -121,9 +121,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", @@ -199,9 +199,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -209,9 +209,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -221,23 +221,23 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.5" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -421,9 +421,9 @@ dependencies = [ [[package]] name = "globset" -version = "0.4.18" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" dependencies = [ "aho-corasick", "bstr", @@ -572,9 +572,9 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "normalize-line-endings" @@ -741,9 +741,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -786,9 +786,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -796,22 +796,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -856,9 +856,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" [[package]] name = "smol_str" @@ -872,9 +872,9 @@ dependencies = [ [[package]] name = "spdx" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8da593e30beb790fc9424502eb898320b44e5eb30367dbda1c1edde8e2f32d7" +checksum = "081670c233dfbed55690cc0cd38424e0e24ac1b2673d0b408b3f7b684738dfa9" dependencies = [ "smallvec", ] @@ -896,6 +896,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -917,22 +928,22 @@ checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -947,9 +958,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "indexmap", "serde_core", @@ -971,18 +982,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "unicode-ident" @@ -1047,7 +1058,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -1139,7 +1150,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 948d56c..38b20c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,17 +36,12 @@ rayon = "1.12.0" smol_str = "0.3.6" tempfile = "3.27.0" serde = { version = "1.0.229", features = ["derive"] } -sha2 = "0.11.0" +serde_json = "1" smallvec = "1.16.0" spdx = "0.13.5" thiserror = "2.0.20" toml = "1.1.6" -[dependencies.gix] -version = "0.87.1" -default-features = false -features = ["dirwalk", "max-performance-safe", "revision", "sha1", "status"] - [dev-dependencies] assert_cmd = "2.2.2" criterion = "0.8.2" From a3e1c2d52b71e6817c725fa2626d1f87adce48cc Mon Sep 17 00:00:00 2001 From: Adam Poulemanos Date: Fri, 11 Sep 2026 09:43:24 -0400 Subject: [PATCH 07/12] fix: repair Windows-only build breaks inherited from main - reuse/atomic: file_attributes() on fs::Metadata comes from MetadataExt, not FileAttributesExt (which is for fs::File). - walk/git: gate the OsString import on unix, its only use site (bytes_to_path) is cfg(unix); on Windows it trips -D warnings. Verified with a local x86_64-pc-windows-msvc lib check; native gates still green (400 tests, clippy -D warnings, fmt). --- src/reuse/atomic.rs | 2 +- src/walk/git.rs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/reuse/atomic.rs b/src/reuse/atomic.rs index bdbbf67..07fc206 100644 --- a/src/reuse/atomic.rs +++ b/src/reuse/atomic.rs @@ -457,7 +457,7 @@ fn is_link(meta: &std::fs::Metadata) -> bool { } #[cfg(windows)] { - use std::os::windows::fs::FileAttributesExt; + use std::os::windows::fs::MetadataExt; const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 } diff --git a/src/walk/git.rs b/src/walk/git.rs index d80cacd..239353b 100644 --- a/src/walk/git.rs +++ b/src/walk/git.rs @@ -11,7 +11,9 @@ //! selected content ([`crate::domain::ContentSource`]). use std::collections::{HashMap, HashSet}; -use std::ffi::{OsStr, OsString}; +use std::ffi::OsStr; +#[cfg(unix)] +use std::ffi::OsString; use std::path::{Path, PathBuf}; use crate::domain::ContentSource; From 4a79d93ff1f14b93e60335e1c5f7130f4aad1315 Mon Sep 17 00:00:00 2001 From: Adam Poulemanos Date: Fri, 11 Sep 2026 09:47:40 -0400 Subject: [PATCH 08/12] fix: split platform-divergent resolver test to satisfy Windows -D warnings The non_executable test ended its cfg(not(unix)) branch with return, making the shared assert unreachable on Windows (deny unreachable_code). Split into a cfg(unix) negative test and a cfg(not(unix)) positive test that also pins the accept-a-plain-file semantics. --- src/tool.rs | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/src/tool.rs b/src/tool.rs index 25775ee..7e12585 100644 --- a/src/tool.rs +++ b/src/tool.rs @@ -133,29 +133,34 @@ mod tests { assert_eq!(resolve_in("curl", &join_path(&[bin.path()])), None); } + #[cfg(unix)] #[test] fn non_executable_file_is_not_a_match() { + use std::os::unix::fs::PermissionsExt; let dir = tempfile::TempDir::new().unwrap(); std::fs::write(dir.path().join("git"), b"not executable").unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(dir.path().join("git")) - .unwrap() - .permissions(); - perms.set_mode(0o644); - std::fs::set_permissions(dir.path().join("git"), perms).unwrap(); - } - #[cfg(not(unix))] - { - // On non-Unix every regular file counts; just assert the positive - // path instead of fighting platform semantics here. - assert!(is_executable_file(&dir.path().join("git"))); - return; - } + let mut perms = std::fs::metadata(dir.path().join("git")) + .unwrap() + .permissions(); + perms.set_mode(0o644); + std::fs::set_permissions(dir.path().join("git"), perms).unwrap(); assert_eq!(resolve_in("git", &join_path(&[dir.path()])), None); } + /// On non-Unix every regular file counts as executable; the resolver + /// accepts a plain file there rather than fighting platform semantics. + #[cfg(not(unix))] + #[test] + fn regular_file_is_a_match() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write(dir.path().join("git"), b"not executable").unwrap(); + assert!(is_executable_file(&dir.path().join("git"))); + assert_eq!( + resolve_in("git", &join_path(&[dir.path()])), + Some(dir.path().join("git")) + ); + } + #[test] fn unresolved_error_names_the_tool() { let err = UnresolvedTool { From 98885869994aee8dcadf9d4cc54599644af386aa Mon Sep 17 00:00:00 2001 From: Adam Poulemanos Date: Fri, 11 Sep 2026 09:53:10 -0400 Subject: [PATCH 09/12] fix: force globset backslash escapes so oob matching is platform-independent globset disables backslash escapes by default where backslash is a path separator (Windows), turning the escaped literals our REUSE pattern compiler emits (for ?[]{} and backslash escapes) into path separators. Force backslash_escape(true) for both REUSE.toml and dep5 patterns; candidates are already forward-slash normalized, so matching is now identical on every OS. No-op on Unix (already the default). --- src/reuse/oob/parse.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/reuse/oob/parse.rs b/src/reuse/oob/parse.rs index b85e4e0..272f648 100644 --- a/src/reuse/oob/parse.rs +++ b/src/reuse/oob/parse.rs @@ -284,7 +284,16 @@ fn compile_reuse_pattern(pattern: &str) -> ReuseMatcher { } else { out }; - match GlobBuilder::new(&full).literal_separator(true).build() { + // Force backslash escapes on every platform: globset disables them by + // default where `\` is a path separator (Windows), which would turn our + // emitted `\?`, `\[`, `\X` literals into separators. licet generates + // this pattern text itself and matches `/`-normalized candidates, so + // matching must be identical on all platforms. + match GlobBuilder::new(&full) + .literal_separator(true) + .backslash_escape(true) + .build() + { Ok(g) => ReuseMatcher::Glob(g.compile_matcher()), Err(_) => ReuseMatcher::Literal(pattern.to_string()), } @@ -293,7 +302,13 @@ fn compile_reuse_pattern(pattern: &str) -> ReuseMatcher { /// Compile one `.reuse/dep5` `Files:` pattern: shell-style wildcards where /// `*` crosses `/` (verified against the reference tool), unlike REUSE.toml. fn compile_dep5_pattern(pattern: &str) -> ReuseMatcher { - match GlobBuilder::new(pattern).literal_separator(false).build() { + // Same platform-independence requirement as REUSE.toml patterns above: + // dep5 `Files:` entries must match identically on every OS. + match GlobBuilder::new(pattern) + .literal_separator(false) + .backslash_escape(true) + .build() + { Ok(g) => ReuseMatcher::Glob(g.compile_matcher()), Err(_) => ReuseMatcher::Literal(pattern.to_string()), } From f75f4cef801ae1b732a6991fbc7b6a5129bea058 Mon Sep 17 00:00:00 2001 From: Adam Poulemanos Date: Fri, 11 Sep 2026 10:00:01 -0400 Subject: [PATCH 10/12] diag: surface licet stderr in LicenseRef scaling test assertion --- tests/perf.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/perf.rs b/tests/perf.rs index 197a08c..8fc7d5b 100644 --- a/tests/perf.rs +++ b/tests/perf.rs @@ -60,7 +60,12 @@ fn long_licenseref_scales_without_quadratic_blowup() { .output() .unwrap(); // Completes (wrong license, missing custom text) rather than hanging. - assert_eq!(out.status.code(), Some(1)); + assert_eq!( + out.status.code(), + Some(1), + "licet stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); t0.elapsed().as_secs_f64() } From cb6f4760774bbbcd03bdfb531bf3c1aa92175ba1 Mon Sep 17 00:00:00 2001 From: Adam Poulemanos Date: Fri, 11 Sep 2026 10:05:48 -0400 Subject: [PATCH 11/12] fix: tolerate Windows path-spelling aliases in explicit file selection Git reports the repo root in long form (C:/Users/runneradmin/...) while the process cwd can carry 8.3 short-name aliases (C:\Users\RUNNER~1\...), so lexical strip_prefix rejected in-root --files inputs as outside the project root (exit 2). Compare both sides through a spelling-normalized form on Windows (canonicalize, with nearest-ancestor fallback for missing inputs); Unix keeps the untouched lexical path. Also applied to explicit --config resolution, which shares the comparison. --- src/walk/mod.rs | 71 ++++++++++++++++++++++++++++++++++++++++++++++--- tests/perf.rs | 7 +---- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/src/walk/mod.rs b/src/walk/mod.rs index baeb299..396fc74 100644 --- a/src/walk/mod.rs +++ b/src/walk/mod.rs @@ -8,7 +8,9 @@ //! [`git::Snapshot`] so `--staged` evaluates index blobs — including staged //! metadata, configuration, and license texts — while everything else reads //! working-tree bytes. Explicit file arguments are normalized lexically -//! (no symlink resolution) and rejected when outside the root. +//! (no symlink resolution; on Windows both sides additionally compare +//! through a spelling-normalized form so 8.3 short-name aliases match the +//! long form `git rev-parse` reports) and rejected when outside the root. pub mod git; @@ -294,7 +296,10 @@ fn resolve_config_rel(cwd: &Path, root: &Path, config_arg: &Path) -> Option Result Result PathBuf { + let mut rest = Vec::new(); + let mut ancestor = path; + loop { + if let Ok(canonical) = std::fs::canonicalize(ancestor) { + let mut out = canonical; + for comp in rest.iter().rev() { + out.push(comp); + } + return out; + } + let Some(parent) = ancestor.parent() else { + return path.to_path_buf(); + }; + if let Some(name) = ancestor.file_name() { + rest.push(name.to_os_string()); + } + ancestor = parent; + } +} + +/// On Unix the lexical path already compares correctly against the +/// git-reported root (the kernel resolves alias spellings in `current_dir`), +/// so comparison needs no filesystem touch. +#[cfg(not(windows))] +fn comparison_spelling(path: &Path) -> PathBuf { + path.to_path_buf() +} + /// Lexically normalize `.`/`..` without resolving symlinks or touching the /// filesystem. fn lexical_normalize(path: &Path) -> PathBuf { @@ -675,6 +721,23 @@ mod tests { ); } + /// Windows-only: a missing explicit input still compares through its + /// nearest existing ancestor, so absence classifies as unreadable rather + /// than "outside the project root" under aliased cwd spellings. + #[cfg(windows)] + #[test] + fn comparison_spelling_covers_missing_files() { + let dir = tempfile::TempDir::new().unwrap(); + let missing = dir.path().join("no-such-file.rs"); + assert!(!missing.exists()); + assert_eq!( + comparison_spelling(&missing), + std::fs::canonicalize(dir.path()) + .unwrap() + .join("no-such-file.rs") + ); + } + #[test] fn license_filename_variants() { // Spec shape: base plus a dash/dot separator with metadata. diff --git a/tests/perf.rs b/tests/perf.rs index 8fc7d5b..197a08c 100644 --- a/tests/perf.rs +++ b/tests/perf.rs @@ -60,12 +60,7 @@ fn long_licenseref_scales_without_quadratic_blowup() { .output() .unwrap(); // Completes (wrong license, missing custom text) rather than hanging. - assert_eq!( - out.status.code(), - Some(1), - "licet stderr: {}", - String::from_utf8_lossy(&out.stderr) - ); + assert_eq!(out.status.code(), Some(1)); t0.elapsed().as_secs_f64() } From b70ef327b0d917283be5efef8e913fefd107c620 Mon Sep 17 00:00:00 2001 From: Adam Poulemanos Date: Fri, 11 Sep 2026 10:25:44 -0400 Subject: [PATCH 12/12] refactor: split genuinely multi-concern functions flagged by complexity gate Seven behavior-preserving extractions (no logic changes,Darwin-tested 404 green): - check/explain_one: resolve_explain_target (selection+stat validation) and explain_sources (provenance lines) leave evaluate+render. - oob/lookup: consult_tables (doc/dep5 consultation) and closest_fallbacks (per-field fallback) leave assembly. - oob/parse_dep5: unfold_dep5_paragraphs (pure continuation unfolding) leaves license validation/materialization; + unit test. - oob/write_annotations: render_stanzas (pure TOML serialization) leaves triage/verify/commit; + unit test (copyright shapes, override, escaping, newlines). - git/prefetch: prefetch_want_list (pure fetch-set assembly) leaves the cat-file round trip; + unit test. - atomic_write: stage_replacement (temp+perms+sync) leaves the validate/commit protocol. - init/generate: compressed_ext_rules (uniform-group compression) leaves exact rules/default/sort; + unit test. Left as-is (single coherent concern, flat branching): engine/scan (result assembly), inventory/compute (classify-then-derive pipeline), oob/load_snapshot (three load steps), walk/prepare (selection dispatch), walk/is_reuse_ignored (predicate cascade). --- src/cli/check.rs | 197 +++++++++++++++++++++++----------------- src/cli/init.rs | 121 +++++++++++++++--------- src/reuse/atomic.rs | 79 +++++++++------- src/reuse/oob/lookup.rs | 153 ++++++++++++++++++------------- src/reuse/oob/parse.rs | 158 +++++++++++++++++--------------- src/reuse/oob/tests.rs | 90 ++++++++++++++++++ src/reuse/oob/write.rs | 101 ++++++++++++-------- src/walk/git.rs | 98 ++++++++++++++------ 8 files changed, 639 insertions(+), 358 deletions(-) diff --git a/src/cli/check.rs b/src/cli/check.rs index 7ff517d..198587b 100644 --- a/src/cli/check.rs +++ b/src/cli/check.rs @@ -127,58 +127,16 @@ fn explain_one( cwd: &std::path::Path, target: &std::path::Path, ) -> Result { - let (root, _) = discover_root(cwd)?; - // Normalize through the explicit-file pipeline: outside-root and - // nonregular inputs fail here as usage errors. - let single = walk::prepare( - cwd, - &args.common.config_arg(cwd, &root), - &Selection::Files(vec![target.to_path_buf()]), - Purpose::Policy, - false, - )?; - let discovered = single.paths.first().cloned().ok_or_else(|| { - LicetError::Internal(format!( - "--explain: no evaluable path for {}", - target.display() - )) - })?; - let rel = discovered.rel_path.clone(); - match std::fs::symlink_metadata(single.root.join(&rel)) { - Ok(_) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return Err(LicetError::Config(format!( - "--explain: no such file {}", - target.display() - ))); - } - Err(e) => { - return Err(LicetError::Config(format!( - "--explain: cannot stat {}: {e}", - target.display() - ))); - } - } - // An explicit selection restricts the answerable set. - if !matches!(args.common.selection()?, Selection::FullTree) { - let selected = walk::prepare( - cwd, - &args.common.config_arg(cwd, &root), - &args.common.selection()?, - Purpose::Policy, - false, - )?; - if !selected.paths.iter().any(|d| d.rel_path == rel) { - return Err(LicetError::Config(format!( - "--explain: {} is outside the selected file set", - target.display() - ))); - } - } - - let config = LicensingConfiguration::from_toml(&single.config_text)?; - let engine = Engine::new(single.root.clone(), &config, single.snapshot.clone(), true); - let scan = engine.scan(std::slice::from_ref(&discovered))?; + let resolved = resolve_explain_target(args, cwd, target)?; + let rel = resolved.rel.clone(); + let config = LicensingConfiguration::from_toml(&resolved.prepared.config_text)?; + let engine = Engine::new( + resolved.prepared.root.clone(), + &config, + resolved.prepared.snapshot.clone(), + true, + ); + let scan = engine.scan(std::slice::from_ref(&resolved.discovered))?; let state = scan.states.first().ok_or_else(|| { LicetError::Internal(format!("--explain: no state for {}", target.display())) })?; @@ -221,32 +179,7 @@ fn explain_one( let excludes = walk::build_excludes(&config.exclude)?; let slash = rel.to_string_lossy().replace('\\', "/"); - let mut sources = Vec::new(); - if let Some(s) = &state.actual.detected_source { - sources.push(format!("detected via {}", s.as_str())); - } - if let Some(oob) = &state.actual.out_of_band { - for o in &oob.origins { - sources.push(format!( - "{} table {} ({})", - o.metadata_path.to_string_lossy().replace('\\', "/"), - o.table_index, - o.precedence.as_str() - )); - } - } - if !state.actual.snippet_licenses.is_empty() { - sources.push(format!( - "{} snippet license(s) (inventoried, never file policy)", - state.actual.snippet_licenses.len() - )); - } - if !state.actual.invalid_license_values.is_empty() { - sources.push(format!( - "{} rejected license value(s), kept for diagnosis", - state.actual.invalid_license_values.len() - )); - } + let sources = explain_sources(state); let rel_display = slash.clone(); match args.common.format { @@ -258,7 +191,7 @@ fn explain_one( scan.warnings, None, crate::report::ReportMeta { - snapshot: single.snapshot.source().as_str().to_string(), + snapshot: resolved.prepared.snapshot.source().as_str().to_string(), complete: true, ..Default::default() }, @@ -275,11 +208,113 @@ fn explain_one( conflict_rules, losers, excluded_by_config: excludes.is_match(&slash), - reuse_ignored: discovered.reuse_ignored, + reuse_ignored: resolved.discovered.reuse_ignored, sources, - snapshot: single.snapshot.source().as_str(), + snapshot: resolved.prepared.snapshot.source().as_str(), }))?; } } Ok(ExitCode::Success) } + +/// An `--explain` target resolved against the selection pipeline: the +/// single-file evaluation plan, the discovered entry, and its root-relative +/// path. Outside-root and nonregular inputs fail here as usage errors, as +/// does a target outside an explicitly selected file set. +struct ExplainTarget { + prepared: walk::Prepared, + discovered: walk::Discovered, + rel: std::path::PathBuf, +} + +fn resolve_explain_target( + args: &CheckArgs, + cwd: &std::path::Path, + target: &std::path::Path, +) -> Result { + let (root, _) = discover_root(cwd)?; + // Normalize through the explicit-file pipeline: outside-root and + // nonregular inputs fail here as usage errors. + let prepared = walk::prepare( + cwd, + &args.common.config_arg(cwd, &root), + &Selection::Files(vec![target.to_path_buf()]), + Purpose::Policy, + false, + )?; + let discovered = prepared.paths.first().cloned().ok_or_else(|| { + LicetError::Internal(format!( + "--explain: no evaluable path for {}", + target.display() + )) + })?; + let rel = discovered.rel_path.clone(); + match std::fs::symlink_metadata(prepared.root.join(&rel)) { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(LicetError::Config(format!( + "--explain: no such file {}", + target.display() + ))); + } + Err(e) => { + return Err(LicetError::Config(format!( + "--explain: cannot stat {}: {e}", + target.display() + ))); + } + } + // An explicit selection restricts the answerable set. + if !matches!(args.common.selection()?, Selection::FullTree) { + let selected = walk::prepare( + cwd, + &args.common.config_arg(cwd, &root), + &args.common.selection()?, + Purpose::Policy, + false, + )?; + if !selected.paths.iter().any(|d| d.rel_path == rel) { + return Err(LicetError::Config(format!( + "--explain: {} is outside the selected file set", + target.display() + ))); + } + } + Ok(ExplainTarget { + prepared, + discovered, + rel, + }) +} + +/// Human provenance lines for an evaluated file: detection source, +/// out-of-band table origins, and inventoried-but-non-policy findings. +fn explain_sources(state: &crate::domain::FileLicensingState) -> Vec { + let mut sources = Vec::new(); + if let Some(s) = &state.actual.detected_source { + sources.push(format!("detected via {}", s.as_str())); + } + if let Some(oob) = &state.actual.out_of_band { + for o in &oob.origins { + sources.push(format!( + "{} table {} ({})", + o.metadata_path.to_string_lossy().replace('\\', "/"), + o.table_index, + o.precedence.as_str() + )); + } + } + if !state.actual.snippet_licenses.is_empty() { + sources.push(format!( + "{} snippet license(s) (inventoried, never file policy)", + state.actual.snippet_licenses.len() + )); + } + if !state.actual.invalid_license_values.is_empty() { + sources.push(format!( + "{} rejected license value(s), kept for diagnosis", + state.actual.invalid_license_values.len() + )); + } + sources +} diff --git a/src/cli/init.rs b/src/cli/init.rs index 0c3f434..717f064 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -225,48 +225,7 @@ struct GeneratedConfig { /// Exact-path rules first; compress to extension rules only for provably /// uniform groups; default only when every covered file is known. fn generate(observations: &[Observation]) -> Result { - // Extension groups: lowercase ext -> (emitted spelling, licenses, paths). - let mut ext_groups: BTreeMap)> = BTreeMap::new(); - let mut ext_of_unknown: std::collections::BTreeSet = Default::default(); - for o in observations { - let Some(ext) = o.path.extension().and_then(|e| e.to_str()) else { - continue; - }; - let key = ext.to_ascii_lowercase(); - match &o.license { - Some(lic) => { - ext_groups - .entry(key) - .or_insert_with(|| (ext.to_string(), Vec::new())) - .1 - .push(lic.clone()); - } - None => { - ext_of_unknown.insert(key); - } - } - } - - // An ext group compresses only when every observed path it would match - // carries the same license — i.e. no unknown file shares the extension - // and all known ones agree. - let mut rules: Vec = Vec::new(); - let mut ext_rule_for: std::collections::BTreeSet = Default::default(); - for (key, (spelling, licenses)) in &ext_groups { - if ext_of_unknown.contains(key) { - continue; - } - let mut iter = licenses.iter(); - let first = iter.next().expect("group is nonempty"); - if iter.all(|l| crate::spdx::expressions_equal(l, first)) { - rules.push(GeneratedRule { - ext: Some(spelling.clone()), - file: None, - license: first.clone(), - }); - ext_rule_for.insert(key.clone()); - } - } + let (mut rules, ext_rule_for) = compressed_ext_rules(observations); // …exact rules for everything they do not cover. for o in observations { let Some(lic) = &o.license else { continue }; @@ -327,6 +286,56 @@ fn generate(observations: &[Observation]) -> Result { /// exact already; a root-level file is emitted as `./name` so the loader pins /// it as an [`ExactPath`](crate::domain::Selector::ExactPath) instead of a /// directory-spanning filename match. +/// Compress uniform extension groups into ext rules: lowercase ext hands +/// back the emitted rules plus the set of extensions they cover (exact +/// rules skip those). An ext group compresses only when every observed path +/// it would match carries the same license — i.e. no unknown file shares +/// the extension and all known ones agree. +fn compressed_ext_rules( + observations: &[Observation], +) -> (Vec, std::collections::BTreeSet) { + // Extension groups: lowercase ext -> (emitted spelling, licenses, paths). + let mut ext_groups: BTreeMap)> = BTreeMap::new(); + let mut ext_of_unknown: std::collections::BTreeSet = Default::default(); + for o in observations { + let Some(ext) = o.path.extension().and_then(|e| e.to_str()) else { + continue; + }; + let key = ext.to_ascii_lowercase(); + match &o.license { + Some(lic) => { + ext_groups + .entry(key) + .or_insert_with(|| (ext.to_string(), Vec::new())) + .1 + .push(lic.clone()); + } + None => { + ext_of_unknown.insert(key); + } + } + } + + let mut rules: Vec = Vec::new(); + let mut ext_rule_for: std::collections::BTreeSet = Default::default(); + for (key, (spelling, licenses)) in &ext_groups { + if ext_of_unknown.contains(key) { + continue; + } + let mut iter = licenses.iter(); + let first = iter.next().expect("group is nonempty"); + if iter.all(|l| crate::spdx::expressions_equal(l, first)) { + rules.push(GeneratedRule { + ext: Some(spelling.clone()), + file: None, + license: first.clone(), + }); + ext_rule_for.insert(key.clone()); + } + } + (rules, ext_rule_for) +} + fn exact_value(rel: &Path) -> String { let s = slash(rel); if s.contains('/') { s } else { format!("./{s}") } @@ -483,6 +492,34 @@ fn render_config(generated: &GeneratedConfig) -> String { mod tests { use super::*; + #[test] + fn ext_compression_needs_uniform_known_licenses() { + // Uniform known licenses compress to one ext rule; a disagreeing + // license or an unknown file sharing the extension blocks it (those + // observations fall through to exact rules). + let obs = |path: &str, license: Option<&str>| Observation { + path: PathBuf::from(path), + license: license.map(str::to_string), + }; + let observations = vec![ + obs("a.rs", Some("MIT")), + obs("sub/b.rs", Some("MIT")), + obs("c.py", Some("MIT")), + obs("d.py", Some("Apache-2.0")), + obs("e.md", None), + obs("f.md", Some("MIT")), + obs("Makefile", Some("MIT")), + ]; + let (rules, covered) = compressed_ext_rules(&observations); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].ext.as_deref(), Some("rs")); + assert_eq!(rules[0].license, "MIT"); + assert_eq!( + covered, + std::collections::BTreeSet::from(["rs".to_string()]) + ); + } + #[test] fn generated_toml_is_well_formed_for_adversarial_values() { // Adversarial values (quotes, backslashes, newlines) must be escaped, not diff --git a/src/reuse/atomic.rs b/src/reuse/atomic.rs index 07fc206..820cfad 100644 --- a/src/reuse/atomic.rs +++ b/src/reuse/atomic.rs @@ -216,39 +216,7 @@ pub fn atomic_write( let dest_state = classify_destination(&dest, relative, expected)?; - // Exclusively-created temp file in the destination directory: random name, so - // a pre-existing predictable `.licet.tmp` (regular file or symlink) is - // never touched. - let mut tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| { - WriteError::before(io::Error::new( - e.kind(), - format!("cannot create temp file for {}: {e}", relative.display()), - )) - })?; - if let Err(e) = (|| { - tmp.write_all(replacement)?; - tmp.flush()?; - match &dest_state { - Dest::Present { permissions } => { - tmp.as_file().set_permissions(permissions.clone())?; - } - #[cfg(unix)] - Dest::Absent => { - use std::os::unix::fs::PermissionsExt; - tmp.as_file() - .set_permissions(std::fs::Permissions::from_mode(0o644))?; - } - #[cfg(not(unix))] - Dest::Absent => {} - } - tmp.as_file().sync_all()?; - Ok::<(), io::Error>(()) - })() { - return Err(WriteError::before(io::Error::new( - e.kind(), - format!("cannot stage replacement for {}: {e}", relative.display()), - ))); - } + let tmp = stage_replacement(parent, replacement, &dest_state, relative)?; // Re-verify the destination immediately before replacing it. recheck(&dest, relative, expected)?; @@ -295,6 +263,51 @@ pub fn atomic_write( Ok(()) } +/// Stage `replacement` in an exclusively-created temp file beside the +/// destination: random name, so a pre-existing predictable +/// `.licet.tmp` (regular file or symlink) is never touched. +/// Destination permissions carry over on replace (`0o644` on Unix +/// creation); content is fsynced. Pure preparation — nothing is committed. +fn stage_replacement( + parent: &Path, + replacement: &[u8], + dest_state: &Dest, + relative: &Path, +) -> Result { + let mut tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| { + WriteError::before(io::Error::new( + e.kind(), + format!("cannot create temp file for {}: {e}", relative.display()), + )) + })?; + (|| { + tmp.write_all(replacement)?; + tmp.flush()?; + match dest_state { + Dest::Present { permissions } => { + tmp.as_file().set_permissions(permissions.clone())?; + } + #[cfg(unix)] + Dest::Absent => { + use std::os::unix::fs::PermissionsExt; + tmp.as_file() + .set_permissions(std::fs::Permissions::from_mode(0o644))?; + } + #[cfg(not(unix))] + Dest::Absent => {} + } + tmp.as_file().sync_all()?; + Ok::<(), io::Error>(()) + })() + .map_err(|e| { + WriteError::before(io::Error::new( + e.kind(), + format!("cannot stage replacement for {}: {e}", relative.display()), + )) + })?; + Ok(tmp) +} + /// Validate `relative` and join it onto an already-canonical root. /// /// Rejects absolute paths and any `..` component; `.` components are skipped diff --git a/src/reuse/oob/lookup.rs b/src/reuse/oob/lookup.rs index 8d3a3d6..7e10c3d 100644 --- a/src/reuse/oob/lookup.rs +++ b/src/reuse/oob/lookup.rs @@ -16,32 +16,7 @@ impl OutOfBand { /// `closest` fallbacks, and every contributing table's provenance. pub fn lookup(&self, rel_path: &Path) -> Option { let rel_str = rel_path.to_string_lossy().replace('\\', "/"); - // Per-document last match, root-first, stopping after an override. - let mut consulted: Vec<(&ReuseDoc, &OobTable)> = Vec::new(); - for doc in &self.docs { - let Some(remainder) = under_base(&rel_str, &doc.base) else { - continue; - }; - if let Some(table) = doc - .tables - .iter() - .rev() - .find(|t| matches_any(&t.matchers, remainder)) - { - let is_override = table.precedence == Precedence::Override; - consulted.push((doc, table)); - if is_override { - break; - } - } - } - // dep5 paragraphs aggregate; the last matching paragraph wins (as in - // the reference tool), contributing alongside any REUSE tables. - let dep5_hit = self - .dep5 - .iter() - .rev() - .find(|p| matches_any(&p.matchers, &rel_str)); + let (consulted, dep5_hit) = self.consult_tables(&rel_str); if consulted.is_empty() && dep5_hit.is_none() { return None; } @@ -98,48 +73,21 @@ impl OutOfBand { } // Per-field nearest-outward `closest` fallback (deepest consulted // table wins each field independently). - let mut fallback_licenses: Vec = Vec::new(); - let mut fallback_copyrights: Vec = Vec::new(); - let mut fallback_lic_origin: Option = None; - let mut fallback_cpr_origin: Option = None; - for (doc, table) in consulted.iter().rev() { - if table.precedence != Precedence::Closest { - continue; - } - if fallback_licenses.is_empty() && !table.licenses.is_empty() { - fallback_licenses = table.licenses.clone(); - fallback_lic_origin = Some(MetadataOrigin { - metadata_path: doc.rel.clone(), - table_index: table.index, - precedence: table.precedence, - licenses: table.licenses.clone(), - copyrights: table.copyrights.clone(), - }); - } - if fallback_copyrights.is_empty() && !table.copyrights.is_empty() { - fallback_copyrights = table.copyrights.clone(); - fallback_cpr_origin = Some(MetadataOrigin { - metadata_path: doc.rel.clone(), - table_index: table.index, - precedence: table.precedence, - licenses: table.licenses.clone(), - copyrights: table.copyrights.clone(), - }); - } - } - if fallback_lic_origin + let fb = closest_fallbacks(&consulted); + if fb + .lic_origin .as_ref() - .is_some_and(|o| Some(o) != fallback_cpr_origin.as_ref()) + .is_some_and(|o| Some(o) != fb.cpr_origin.as_ref()) { - origins.push(fallback_lic_origin.expect("checked")); + origins.push(fb.lic_origin.expect("checked")); } - if let Some(o) = fallback_cpr_origin { + if let Some(o) = fb.cpr_origin { origins.push(o); } if licenses.is_empty() && copyrights.is_empty() - && fallback_licenses.is_empty() - && fallback_copyrights.is_empty() + && fb.licenses.is_empty() + && fb.copyrights.is_empty() { // Tables matched but none carries any licensing information — an // override barrier still suppresses the file (reference behavior), @@ -161,14 +109,49 @@ impl OutOfBand { source: primary_source, licenses, copyrights, - fallback_licenses, - fallback_copyrights, + fallback_licenses: fb.licenses, + fallback_copyrights: fb.copyrights, suppresses_file: barrier, precedence, origins, }) } + /// Tables consulted for a normalized repo-relative path: per-document + /// last match, root-first, stopping after an override barrier — plus the + /// last matching dep5 paragraph, which aggregates alongside REUSE tables. + fn consult_tables<'a>( + &'a self, + rel_str: &str, + ) -> (Vec<(&'a ReuseDoc, &'a OobTable)>, Option<&'a Dep5Para>) { + let mut consulted: Vec<(&ReuseDoc, &OobTable)> = Vec::new(); + for doc in &self.docs { + let Some(remainder) = under_base(rel_str, &doc.base) else { + continue; + }; + if let Some(table) = doc + .tables + .iter() + .rev() + .find(|t| matches_any(&t.matchers, remainder)) + { + let is_override = table.precedence == Precedence::Override; + consulted.push((doc, table)); + if is_override { + break; + } + } + } + // dep5 paragraphs aggregate; the last matching paragraph wins (as in + // the reference tool), contributing alongside any REUSE tables. + let dep5_hit = self + .dep5 + .iter() + .rev() + .find(|p| matches_any(&p.matchers, rel_str)); + (consulted, dep5_hit) + } + pub fn is_empty(&self) -> bool { self.docs.is_empty() && self.dep5.is_empty() } @@ -180,6 +163,50 @@ impl OutOfBand { } } +/// Per-field `closest` fallback resolved from the consulted tables: the +/// deepest consulted `closest` table wins each field independently. +struct ClosestFallbacks { + licenses: Vec, + copyrights: Vec, + lic_origin: Option, + cpr_origin: Option, +} + +fn closest_fallbacks(consulted: &[(&ReuseDoc, &OobTable)]) -> ClosestFallbacks { + let mut fb = ClosestFallbacks { + licenses: Vec::new(), + copyrights: Vec::new(), + lic_origin: None, + cpr_origin: None, + }; + for (doc, table) in consulted.iter().rev() { + if table.precedence != Precedence::Closest { + continue; + } + if fb.licenses.is_empty() && !table.licenses.is_empty() { + fb.licenses = table.licenses.clone(); + fb.lic_origin = Some(MetadataOrigin { + metadata_path: doc.rel.clone(), + table_index: table.index, + precedence: table.precedence, + licenses: table.licenses.clone(), + copyrights: table.copyrights.clone(), + }); + } + if fb.copyrights.is_empty() && !table.copyrights.is_empty() { + fb.copyrights = table.copyrights.clone(); + fb.cpr_origin = Some(MetadataOrigin { + metadata_path: doc.rel.clone(), + table_index: table.index, + precedence: table.precedence, + licenses: table.licenses.clone(), + copyrights: table.copyrights.clone(), + }); + } + } + fb +} + fn push_unique(target: &mut Vec, iter: impl Iterator) { for item in iter { if !target.contains(&item) { diff --git a/src/reuse/oob/parse.rs b/src/reuse/oob/parse.rs index 272f648..f9aec43 100644 --- a/src/reuse/oob/parse.rs +++ b/src/reuse/oob/parse.rs @@ -134,82 +134,13 @@ impl OutOfBand { Ok(()) } /// Debian dep5 (`.reuse/dep5`) parser: `Files:`/`Copyright:`/`License:` - /// paragraphs (field names ASCII-case-insensitive) with continuation-line - /// unfolding — a line starting with whitespace continues the current - /// field, and a lone `.` is a blank. Unknown fields are preserved-ignored + /// paragraphs with continuation-line unfolding (see + /// [`unfold_dep5_paragraphs`]). Unknown fields are preserved-ignored /// without being interpreted. An invalid `License:` expression fails like /// any other malformed metadata. pub(crate) fn parse_dep5(&mut self, text: &str) -> crate::error::Result<()> { use crate::error::LicetError; - #[derive(Default)] - struct Para { - files: String, - copyright: Vec, - license: String, - field: Option, - } - #[derive(Clone, Copy, PartialEq, Eq)] - enum Dep5Field { - Files, - Copyright, - License, - Other, - } - let mut paras: Vec = vec![Para::default()]; - for line in text.lines() { - if line.trim().is_empty() { - paras.push(Para::default()); - continue; - } - let para = paras.last_mut().expect("paragraphs never empty"); - if line.starts_with(' ') || line.starts_with('\t') { - // Continuation of the current field (a lone `.` is blank). - let cont = line.trim(); - if cont.is_empty() || cont == "." { - continue; - } - match para.field { - Some(Dep5Field::Files) => { - para.files.push(' '); - para.files.push_str(cont); - } - Some(Dep5Field::Copyright) => para.copyright.push(cont.to_string()), - Some(Dep5Field::License) => { - para.license.push(' '); - para.license.push_str(cont); - } - Some(Dep5Field::Other) | None => {} - } - continue; - } - if cont_is_dot_only(line) { - continue; - } - let (name, value) = match line.split_once(':') { - Some((n, v)) => (n.trim(), v.trim()), - None => continue, - }; - if name.eq_ignore_ascii_case("Files") { - para.field = Some(Dep5Field::Files); - if !para.files.is_empty() { - para.files.push(' '); - } - para.files.push_str(value); - } else if name.eq_ignore_ascii_case("Copyright") { - para.field = Some(Dep5Field::Copyright); - if !value.is_empty() { - para.copyright.push(value.to_string()); - } - } else if name.eq_ignore_ascii_case("License") { - para.field = Some(Dep5Field::License); - if !para.license.is_empty() { - para.license.push(' '); - } - para.license.push_str(value); - } else { - para.field = Some(Dep5Field::Other); - } - } + let paras = unfold_dep5_paragraphs(text); for (index, para) in paras.into_iter().enumerate() { if para.files.trim().is_empty() { continue; @@ -240,6 +171,89 @@ impl OutOfBand { } } +/// One unfolded dep5 paragraph: raw field text before license validation +/// and pattern compilation. +#[derive(Default)] +pub(crate) struct Dep5ParaRaw { + pub(crate) files: String, + pub(crate) copyright: Vec, + pub(crate) license: String, + /// Current field for continuation lines; transient unfold state. + field: Option, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Dep5Field { + Files, + Copyright, + License, + Other, +} + +/// Unfold a dep5 document into raw paragraphs: field names are +/// ASCII-case-insensitive, a line starting with whitespace continues the +/// current field, a lone `.` is a blank, and unknown fields are +/// preserved-ignored without being interpreted. Pure line processing, so +/// continuation edge cases are directly unit-testable. +pub(crate) fn unfold_dep5_paragraphs(text: &str) -> Vec { + let mut paras: Vec = vec![Dep5ParaRaw::default()]; + for line in text.lines() { + if line.trim().is_empty() { + paras.push(Dep5ParaRaw::default()); + continue; + } + let para = paras.last_mut().expect("paragraphs never empty"); + if line.starts_with(' ') || line.starts_with('\t') { + // Continuation of the current field (a lone `.` is blank). + let cont = line.trim(); + if cont.is_empty() || cont == "." { + continue; + } + match para.field { + Some(Dep5Field::Files) => { + para.files.push(' '); + para.files.push_str(cont); + } + Some(Dep5Field::Copyright) => para.copyright.push(cont.to_string()), + Some(Dep5Field::License) => { + para.license.push(' '); + para.license.push_str(cont); + } + Some(Dep5Field::Other) | None => {} + } + continue; + } + if cont_is_dot_only(line) { + continue; + } + let (name, value) = match line.split_once(':') { + Some((n, v)) => (n.trim(), v.trim()), + None => continue, + }; + if name.eq_ignore_ascii_case("Files") { + para.field = Some(Dep5Field::Files); + if !para.files.is_empty() { + para.files.push(' '); + } + para.files.push_str(value); + } else if name.eq_ignore_ascii_case("Copyright") { + para.field = Some(Dep5Field::Copyright); + if !value.is_empty() { + para.copyright.push(value.to_string()); + } + } else if name.eq_ignore_ascii_case("License") { + para.field = Some(Dep5Field::License); + if !para.license.is_empty() { + para.license.push(' '); + } + para.license.push_str(value); + } else { + para.field = Some(Dep5Field::Other); + } + } + paras +} + /// A lone `.` line outside a field continuation is blank padding, not content. fn cont_is_dot_only(line: &str) -> bool { line.trim() == "." diff --git a/src/reuse/oob/tests.rs b/src/reuse/oob/tests.rs index 0d12bd6..7ecb76f 100644 --- a/src/reuse/oob/tests.rs +++ b/src/reuse/oob/tests.rs @@ -91,6 +91,35 @@ fn dep5_continuation_lines_unfold() { ); } +#[test] +fn unfold_dep5_paragraphs_joins_continuations() { + // Raw unfolding: continuation lines join with a space, a lone `.` is + // blank, field names match ASCII-case-insensitively, unknown fields + // neither set state nor leak content, and repeated `Files:` accumulate. + let paras = super::parse::unfold_dep5_paragraphs( + "Format: https://example.test/spec\n\ + \n\ + FILES: a.bin\n sub/b.bin\n\ + copyright: 2026 A\n 2026 B\n .\n\ + Upstream-Name: ignored\n continued-ignored\n\ + License: MIT\n OR Apache-2.0\n\ + \n\ + Files: c.bin\n\ + Copyright: 2027 C\n", + ); + assert_eq!(paras.len(), 3); + assert_eq!(paras[0].files, ""); + assert_eq!(paras[1].files, "a.bin sub/b.bin"); + assert_eq!( + paras[1].copyright, + vec!["2026 A".to_string(), "2026 B".to_string()] + ); + assert_eq!(paras[1].license, "MIT OR Apache-2.0"); + assert_eq!(paras[2].files, "c.bin"); + assert_eq!(paras[2].copyright, vec!["2027 C".to_string()]); + assert_eq!(paras[2].license, ""); +} + #[test] fn dep5_last_paragraph_wins() { // Two paragraphs covering the same file: the last one governs, exactly @@ -559,6 +588,67 @@ fn multiple_copyrights_serialize_as_list() { ); } +#[test] +fn render_stanzas_covers_copyright_shapes() { + // Pure stanza rendering: absent/single/list copyright fields, the + // `override` stanza flag, glob-metachar escaping, and the document's + // newline convention — plus exactly which requests each stanza covers. + use super::write::{AnnotationDestination, AnnotationRequest, render_stanzas}; + let cprs = vec!["2026 Acme".to_string(), "2027 Acme".to_string()]; + let requests = vec![ + AnnotationRequest { + rel_path: "a.png", + license: "MIT", + copyrights: &[], + }, + AnnotationRequest { + rel_path: "sub/b.png", + license: "Apache-2.0", + copyrights: &cprs[..1], + }, + AnnotationRequest { + rel_path: "a*b.png", + license: "CC0-1.0", + copyrights: &cprs, + }, + ]; + let dest = |i: usize, base: &str, use_override: bool| { + ( + i, + AnnotationDestination { + doc_rel: PathBuf::from("REUSE.toml"), + base_path: base.to_string(), + use_override, + }, + ) + }; + let jobs = vec![ + dest(0, "a.png", false), + dest(1, "b.png", true), + dest(2, "a*b.png", false), + ]; + let (text, appended) = render_stanzas(&jobs, &requests, "\r\n"); + assert_eq!(appended, vec![0, 1, 2]); + assert_eq!( + text, + "[[annotations]]\r\n\ + path = \"a.png\"\r\n\ + SPDX-License-Identifier = \"MIT\"\r\n\ + \r\n\ + [[annotations]]\r\n\ + path = \"b.png\"\r\n\ + precedence = \"override\"\r\n\ + SPDX-FileCopyrightText = \"2026 Acme\"\r\n\ + SPDX-License-Identifier = \"Apache-2.0\"\r\n\ + \r\n\ + [[annotations]]\r\n\ + path = \"a\\\\*b.png\"\r\n\ + SPDX-FileCopyrightText = [\"2026 Acme\", \"2027 Acme\"]\r\n\ + SPDX-License-Identifier = \"CC0-1.0\"\r\n\ + \r\n" + ); +} + #[test] fn glob_metachar_path_is_escaped_to_literal() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/reuse/oob/write.rs b/src/reuse/oob/write.rs index 701ca18..b4c51db 100644 --- a/src/reuse/oob/write.rs +++ b/src/reuse/oob/write.rs @@ -251,46 +251,10 @@ pub fn write_annotations( } else if !out.ends_with('\n') { out.push_str(nl); } - for (i, dest) in jobs { - let req = &requests[*i]; - out.push_str("[[annotations]]"); - out.push_str(nl); - out.push_str(&format!( - "path = {}", - toml_string(&escape_reuse_path(&dest.base_path)) - )); - out.push_str(nl); - if dest.use_override { - out.push_str("precedence = \"override\""); - out.push_str(nl); - } - match req.copyrights.len() { - 0 => {} - 1 => { - out.push_str(&format!( - "SPDX-FileCopyrightText = {}", - toml_string(&req.copyrights[0]) - )); - out.push_str(nl); - } - _ => { - let list = req - .copyrights - .iter() - .map(|s| toml_string(s)) - .collect::>() - .join(", "); - out.push_str(&format!("SPDX-FileCopyrightText = [{list}]")); - out.push_str(nl); - } - } - out.push_str(&format!( - "SPDX-License-Identifier = {}", - toml_string(req.license) - )); - out.push_str(nl); - out.push_str(nl); - per_request[*i] = AnnotationWrite::Appended; + let (stanzas, appended) = render_stanzas(jobs, requests, nl); + out.push_str(&stanzas); + for i in appended { + per_request[i] = AnnotationWrite::Appended; } // Re-parse the complete proposed document before committing, and @@ -338,6 +302,63 @@ pub fn write_annotations( }) } +/// Serialize one document's appended stanzas: exact paths with REUSE glob +/// metacharacters escaped, copyrights as absent/single/list fields, and the +/// document's own newline convention. Pure rendering — returns the stanza +/// text plus the covered request indices (callers mark them `Appended` only +/// once the whole document verifies), so TOML shape and escaping are +/// directly unit-testable. +pub(crate) fn render_stanzas( + jobs: &[(usize, AnnotationDestination)], + requests: &[AnnotationRequest<'_>], + nl: &str, +) -> (String, Vec) { + let mut out = String::new(); + let mut appended = Vec::with_capacity(jobs.len()); + for (i, dest) in jobs { + let req = &requests[*i]; + out.push_str("[[annotations]]"); + out.push_str(nl); + out.push_str(&format!( + "path = {}", + toml_string(&escape_reuse_path(&dest.base_path)) + )); + out.push_str(nl); + if dest.use_override { + out.push_str("precedence = \"override\""); + out.push_str(nl); + } + match req.copyrights.len() { + 0 => {} + 1 => { + out.push_str(&format!( + "SPDX-FileCopyrightText = {}", + toml_string(&req.copyrights[0]) + )); + out.push_str(nl); + } + _ => { + let list = req + .copyrights + .iter() + .map(|s| toml_string(s)) + .collect::>() + .join(", "); + out.push_str(&format!("SPDX-FileCopyrightText = [{list}]")); + out.push_str(nl); + } + } + out.push_str(&format!( + "SPDX-License-Identifier = {}", + toml_string(req.license) + )); + out.push_str(nl); + out.push_str(nl); + appended.push(*i); + } + (out, appended) +} + /// Effective OOB copyrights for a lookup entry, mirroring the license logic: /// unconditional contributors first, else the `closest` fallback. fn effective_copyrights(entry: &crate::domain::OutOfBandEntry) -> Vec { diff --git a/src/walk/git.rs b/src/walk/git.rs index 239353b..f3c057c 100644 --- a/src/walk/git.rs +++ b/src/walk/git.rs @@ -228,33 +228,7 @@ impl IndexSnapshot { /// by object id. pub fn prefetch(&mut self, root: &Path, paths: &[PathBuf], extra: &[PathBuf]) -> Result<()> { let mut oids: HashSet<&str> = HashSet::new(); - let mut want: Vec = Vec::with_capacity(paths.len() * 2 + extra.len()); - for p in paths { - want.push(p.clone()); - want.push(sidecar_for(p)); - } - want.extend(extra.iter().cloned()); - // Repository-level metadata and every license text participate in the - // same snapshot so staged metadata/config/text changes are visible. - // `REUSE.toml` documents live at any depth; every one of them (plus - // the root `.reuse/dep5`) is prefetched so nested staged metadata - // resolves through the index, never the working copy. - want.push(PathBuf::from("REUSE.toml")); - want.push(PathBuf::from(".reuse/dep5")); - for (p, e) in &self.entries { - if e.is_regular_file() - && p.file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n == "REUSE.toml") - { - want.push(p.clone()); - } - } - for (p, e) in &self.entries { - if e.is_regular_file() && p.starts_with("LICENSES/") { - want.push(p.clone()); - } - } + let want = self.prefetch_want_list(paths, extra); for p in &want { if let Some(e) = self.entries.get(p) && e.is_regular_file() @@ -303,6 +277,40 @@ impl IndexSnapshot { Ok(()) } + /// Everything one `git cat-file --batch` round must fetch: the requested + /// paths plus their sidecars and `extra` (e.g. the effective config), + /// every `REUSE.toml` document at any depth, the root `.reuse/dep5`, and + /// every license text — so staged metadata/config/text changes resolve + /// through the index, never the working copy. Pure assembly over the + /// index entries, directly unit-testable. + fn prefetch_want_list(&self, paths: &[PathBuf], extra: &[PathBuf]) -> Vec { + let mut want: Vec = Vec::with_capacity(paths.len() * 2 + extra.len()); + for p in paths { + want.push(p.clone()); + want.push(sidecar_for(p)); + } + want.extend(extra.iter().cloned()); + // Repository-level metadata and every license text participate in the + // same snapshot so staged metadata/config/text changes are visible. + want.push(PathBuf::from("REUSE.toml")); + want.push(PathBuf::from(".reuse/dep5")); + for (p, e) in &self.entries { + if e.is_regular_file() + && p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n == "REUSE.toml") + { + want.push(p.clone()); + } + } + for (p, e) in &self.entries { + if e.is_regular_file() && p.starts_with("LICENSES/") { + want.push(p.clone()); + } + } + want + } + /// Blob bytes for a root-relative path: `None` when absent from the index, /// a gitlink, or a symlink (both ignored per REUSE, never followed). /// Unmerged entries and unknown objects are errors, never silent content. @@ -703,4 +711,40 @@ mod tests { let out = b"deadbeef missing\n"; assert!(parse_batch_output(out, &["deadbeef"], &mut blobs).is_err()); } + + #[test] + fn want_list_pairs_sidecars_and_metadata() { + // The fetch set is exactly: requested paths + sidecars + extra, + // root metadata documents, every nested REUSE.toml, and every + // license text — symlinks and gitlinks never qualify. + let entry = |mode: u32| IndexEntry { + mode, + oid: "abc".to_string(), + stage: 0, + }; + let snap = IndexSnapshot { + entries: HashMap::from([ + (PathBuf::from("sub/REUSE.toml"), entry(0o100644)), + (PathBuf::from("LICENSES/MIT.txt"), entry(0o100644)), + (PathBuf::from("link.rs"), entry(0o120000)), + (PathBuf::from("submod"), entry(0o160000)), + ]), + blobs: HashMap::new(), + }; + let mut want = + snap.prefetch_want_list(&[PathBuf::from("a.rs")], &[PathBuf::from("licet.toml")]); + want.sort(); + assert_eq!( + want, + vec![ + PathBuf::from(".reuse/dep5"), + PathBuf::from("LICENSES/MIT.txt"), + PathBuf::from("REUSE.toml"), + PathBuf::from("a.rs"), + PathBuf::from("a.rs.license"), + PathBuf::from("licet.toml"), + PathBuf::from("sub/REUSE.toml"), + ] + ); + } }