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..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), @@ -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 @@ -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/Cargo.lock b/Cargo.lock index 7c1764f..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", ] @@ -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" @@ -84,24 +78,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" - -[[package]] -name = "arc-swap" -version = "1.9.1" +version = "1.0.104" 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" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "assert_cmd" @@ -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" @@ -166,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", @@ -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" @@ -250,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", @@ -260,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", @@ -272,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]] @@ -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" @@ -541,15 +347,6 @@ version = "1.0.0" 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" @@ -567,880 +364,66 @@ dependencies = [ ] [[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", -] - -[[package]] -name = "gix-tempfile" -version = "23.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef60812443484e67bf84e444cc71b4c78ae62deb822221774a4fa0c57fdb17f" -dependencies = [ - "dashmap", - "gix-fs", - "libc", - "parking_lot", - "tempfile", -] - -[[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" -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", -] - -[[package]] -name = "gix-traverse" -version = "0.59.0" +name = "fastrand" +version = "2.4.1" 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 = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] -name = "gix-url" -version = "0.36.1" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bb01ec69d55e82ccb7a19e264501ead4e6aac38463a8cebfdd81e22bb67ab2" -dependencies = [ - "bstr", - "gix-path", - "percent-encoding", - "thiserror", -] +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "gix-utils" -version = "0.3.3" +name = "float-cmp" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66c50966184123caf580ffa64e28031a878597f1c7fceb8fe19566c38eb1b771" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" dependencies = [ - "bstr", - "fastrand", - "unicode-normalization", + "num-traits", ] [[package]] -name = "gix-validate" -version = "0.11.2" +name = "futures-core" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bc6fc771c4063ba7cd2f47b91fb6076251c6a823b64b7fe7b8874b0fe4afae3" -dependencies = [ - "bstr", -] +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +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]] 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", @@ -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,52 +564,17 @@ 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" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "normalize-line-endings" @@ -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" @@ -1980,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", @@ -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,17 +784,11 @@ 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" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -2041,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]] @@ -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" @@ -2139,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" @@ -2155,25 +872,13 @@ 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", ] -[[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" @@ -2191,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" @@ -2212,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]] @@ -2240,26 +956,11 @@ 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" +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", @@ -2281,39 +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" -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" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "unicode-ident" @@ -2321,27 +1001,12 @@ 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" @@ -2393,7 +1058,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -2485,15 +1150,9 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] -[[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 05ac4e4..38b20c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,20 +33,15 @@ globset = "0.4.20" ignore = "0.4.26" memchr = "2.8.3" rayon = "1.12.0" +smol_str = "0.3.6" +tempfile = "3.27.0" serde = { version = "1.0.229", features = ["derive"] } -serde_json = "1.0.151" -sha2 = "0.11.0" +serde_json = "1" smallvec = "1.16.0" -smol_str = "0.3.6" 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" diff --git a/README.md b/README.md index 75ddc71..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. | @@ -86,12 +86,12 @@ 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` ```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,9 +107,11 @@ 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). +`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 +(neither ids nor `--all`, or both). ### Shell completions @@ -124,10 +126,10 @@ 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. -- **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..1f73f9f 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=[\"licet.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("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(); @@ -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("licet.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("licet.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 `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(); + 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/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 diff --git a/specs/001-declarative-license-headers/contracts/cli.md b/specs/001-declarative-license-headers/contracts/cli.md index 43ea9e8..f6f1a6c 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,34 +11,45 @@ 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 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 | 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). | -| `--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,53 @@ 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 `/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` + 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 `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. +- 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,15 +192,25 @@ 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`). -- Writes **only** under `LICENSES/`: it never modifies source files or `license.toml`, and + 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 `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.) @@ -154,9 +224,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 +245,36 @@ 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 + (`/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 + 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 `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 1753470..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 @@ -18,7 +25,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 +45,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 +74,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 +90,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 +114,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..35fcb0a 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 | |-------|------|-------| @@ -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,28 +141,41 @@ 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** +### HeaderBlock | Field | Type | Notes | |-------|------|-------| @@ -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..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. @@ -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. +- **`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 f56c6f8..514648e 100644 --- a/src/cli/add_license.rs +++ b/src/cli/add_license.rs @@ -2,24 +2,31 @@ //! 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 `licet.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; 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}; +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(CONFIG_FILENAME), + }; + 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..198587b 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, + }); + } + + // `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, &[], scan.warnings, None); + 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,227 @@ 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 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())) + })?; + + 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 sources = explain_sources(state); + + 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: resolved.prepared.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: resolved.discovered.reuse_ignored, + sources, + 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 141e7be..717f064 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -1,102 +1,451 @@ -//! `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. +//! 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 +//! 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::config::{ + CONFIG_FILENAME, LicensingConfiguration, has_legacy_only_config, legacy_config_error, +}; 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 `/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(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(CONFIG_FILENAME), + &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, + 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; + } }; - *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) + 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() + ))); + } + + 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() + .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) +} + +fn message_of(report: &Report) -> String { + report + .writes + .first() + .and_then(|w| w.message.clone()) + .unwrap_or_default() +} + +/// 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, +} + +struct GeneratedConfig { + default: Option, + rules: Vec, +} + +/// 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 { + 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 }; + 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 }) +} - // Most common license overall becomes the default. - let default_license = license_counts - .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())); +/// 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. +/// 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 toml = render_config(default_license.as_deref(), &rules); + 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) +} - // Validate the generated config parses (projection sanity). - LicensingConfiguration::from_toml(&toml)?; +fn exact_value(rel: &Path) -> String { + let s = slash(rel); + if s.contains('/') { s } else { format!("./{s}") } +} - let out_path = args - .output - .clone() - .unwrap_or_else(|| PathBuf::from("license.toml")); - std::fs::write(&out_path, &toml)?; +fn slash(p: &Path) -> String { + p.to_string_lossy().replace('\\', "/") +} - 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}"); +/// 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(ExitCode::Success) + 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()) } -/// 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. +fn display_path(root: &Path, abs: &Path) -> String { + match abs.strip_prefix(root) { + Ok(rel) => slash(rel), + Err(_) => abs.to_string_lossy().into_owned(), + } +} + +/// 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. #[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 +459,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(), }; @@ -137,23 +492,62 @@ fn render_config(default: Option<&str>, rules: &[(String, String)]) -> 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 // 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 +558,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..5fe28cd 100644 --- a/src/cli/lint.rs +++ b/src/cli/lint.rs @@ -1,29 +1,176 @@ //! `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 `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, 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}; + +/// 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); - 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 { + check_explicit_config(&cwd, cfg_path)?; + } + + // 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(CONFIG_FILENAME), + &Selection::FullTree, + Purpose::Lint, + true, + )?; + let config = LicensingConfiguration::default(); + + let engine = Engine::new(root.clone(), &config, prep.snapshot.clone(), false); + let scan = engine.scan(&prep.paths)?; + + // 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())); - let mut cache = ScanCache::disabled(); - let engine = Engine::new(root.clone(), &config, &config_text); - let scan = engine.scan(&Selection::FullTree, &mut cache)?; + // 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 inv = LicenseTextInventory::compute(&root, &scan.referenced_ids); + let compliant = failing_files.is_empty() && inv.is_complete() && !incomplete; - // 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(); + // 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 +180,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")); } } - println!( - "Result: {}", + 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)); + } + } + 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..c3e934f 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,11 +6,12 @@ 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; +use crate::config::CONFIG_FILENAME; use crate::error::{ExitCode, LicetError, Result}; use crate::spdx; use crate::walk::Selection; @@ -73,9 +74,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: `/licet.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 +104,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 `/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(CONFIG_FILENAME), + } + } + /// Resolve the file selection, enforcing mutual exclusivity (FR-027). pub fn selection(&self) -> Result { let mut chosen = 0; @@ -194,22 +211,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 `/licet.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 +260,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 `/licet.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 +276,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 +315,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 +325,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..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) } @@ -88,7 +115,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 +154,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 +228,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 +243,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 +272,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 +365,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 +403,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 +418,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 +507,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 +566,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/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/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..7654547 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,11 +10,13 @@ pub mod detect; pub mod domain; pub mod engine; pub mod error; +pub mod exec; pub mod reconcile; 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/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..ad6e547 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,118 @@ 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 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, + intent: &LicenseIntent, + style: &CommentSyntax, + mode: ChangeMode, + 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, + } +} + +/// 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!( + "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; + 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() + ))); + } + 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, @@ -44,62 +155,226 @@ 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() { - 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 { - new_content: Some(new_content), - mode, - target_header: None, - wrote_header: true, - preserved_copyrights: copyrights.len(), - contradiction: false, - }; + return Ok(plan_missing_header( + content, intent, style, mode, license_ok, + )); } - // Edit an existing header block. - let idx = target_header.unwrap_or(0).min(actual.headers.len() - 1); - let block = &actual.headers[idx]; + // 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 block_text = &content[start..end]; 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 +392,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 +608,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 +649,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 +790,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 +809,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..820cfad --- /dev/null +++ b/src/reuse/atomic.rs @@ -0,0 +1,705 @@ +//! 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), + } +} + +/// 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]>, +) -> 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( + io::ErrorKind::InvalidData, + format!( + "destination {} disappeared or was never read; refusing to write", + 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) { + 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 => 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() + ), + ))); + } + 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)?; + + let tmp = stage_replacement(parent, replacement, &dest_state, relative)?; + + // 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(()) +} + +/// 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 +/// (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::MetadataExt; + 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..5aa0829 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,91 @@ 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"); + // 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") + .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 +643,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 +702,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 deleted file mode 100644 index 873f817..0000000 --- a/src/reuse/oob.rs +++ /dev/null @@ -1,522 +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). - -use std::path::Path; - -use globset::{Glob, GlobMatcher}; -use serde::Deserialize; - -use crate::domain::{OobSource, OutOfBandEntry, Precedence}; - -/// All out-of-band annotations discovered under a repository root. -#[derive(Default)] -pub struct OutOfBand { - entries: Vec, -} - -struct OobAnnotation { - matchers: Vec, - license: Option, - copyrights: Vec, - source: OobSource, - precedence: Precedence, -} - -#[derive(Debug, Deserialize)] -struct ReuseToml { - #[serde(default)] - annotations: Vec, -} - -#[derive(Debug, Deserialize)] -struct ReuseAnnotation { - #[serde(default)] - path: PathList, - #[serde(rename = "SPDX-License-Identifier")] - license: Option, - #[serde(rename = "SPDX-FileCopyrightText", default)] - copyright: PathList, - /// REUSE 3.3 `precedence`: `closest` (default) | `aggregate` | `override`. - 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)] -#[serde(untagged)] -enum PathList { - #[default] - None, - One(String), - Many(Vec), -} - -impl PathList { - fn into_vec(self) -> Vec { - match self { - PathList::None => Vec::new(), - PathList::One(s) => vec![s], - PathList::Many(v) => v, - } - } -} - -impl OutOfBand { - /// Load `REUSE.toml` and `.reuse/dep5` from `root`, if present. - pub fn load(root: &Path) -> Self { - 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); - } - let dep5 = root.join(".reuse").join("dep5"); - if let Ok(text) = std::fs::read_to_string(&dep5) { - oob.parse_dep5(&text); - } - 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 { - matchers, - license: ann.license, - copyrights: ann.copyright.into_vec(), - source: OobSource::ReuseToml, - precedence, - }); - } - } - - /// 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() { - 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()); - } - } - flush(&mut files, &mut license, &mut copyrights, &mut self.entries); - } - - /// 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. - pub fn lookup(&self, rel_path: &Path) -> Option { - let matches = |e: &&OobAnnotation| e.matchers.iter().any(|m| m.is_match(rel_path)); - self.entries - .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, - }) - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } -} - -/// Translate a dep5 glob (`foo/*`) into a globset pattern. -fn dep5_glob(s: &str) -> String { - s.to_string() -} - -fn compile_globs(patterns: Vec) -> Vec { - patterns - .iter() - .filter_map(|p| Glob::new(p).ok().map(|g| g.compile_matcher())) - .collect() -} - -/// 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, - /// 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). - Appended, -} - -impl AnnotationWrite { - /// Whether the file was actually rewritten. - pub fn modified(self) -> bool { - !matches!(self, AnnotationWrite::Unchanged) - } -} - -/// Ensure a `REUSE.toml` annotation covers `rel_path` with `license` (FR-015, 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 whole file is rewritten atomically (FR-024). -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) - )); - - crate::reuse::atomic_write(&reuse_toml, &out)?; - Ok(AnnotationWrite::Appended) -} - -/// 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, -} - -/// 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; - 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); - } - _ => {} - } - } - j += 1; - } - blocks.push(AnnBlock { - paths, - license, - license_line, - }); - i = j; - } - blocks -} - -/// 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, - } -} - -/// 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); - } - } - out -} - -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; - - #[test] - fn parses_reuse_toml_glob() { - let mut oob = OutOfBand::default(); - oob.parse_reuse_toml( - "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()]); - } - - #[test] - fn parses_dep5() { - let mut oob = OutOfBand::default(); - oob.parse_dep5("Files: img/*\nCopyright: 2026 Acme\nLicense: MIT\n"); - let e = oob.lookup(&PathBuf::from("img/x.jpg")).unwrap(); - assert_eq!(e.license.as_deref(), Some("MIT")); - assert_eq!(e.source, OobSource::Dep5); - } - - #[test] - fn no_match_is_none() { - let oob = OutOfBand::default(); - assert!(oob.lookup(&PathBuf::from("x")).is_none()); - } - - #[test] - fn precedence_defaults_to_closest() { - let mut oob = OutOfBand::default(); - oob.parse_reuse_toml("[[annotations]]\npath = \"a\"\nSPDX-License-Identifier = \"MIT\"\n"); - assert_eq!( - oob.lookup(&PathBuf::from("a")).unwrap().precedence, - Precedence::Closest - ); - } - - #[test] - fn precedence_override_parsed() { - let mut oob = OutOfBand::default(); - oob.parse_reuse_toml( - "[[annotations]]\npath = \"a\"\nprecedence = \"override\"\nSPDX-License-Identifier = \"MIT\"\n", - ); - assert_eq!( - oob.lookup(&PathBuf::from("a")).unwrap().precedence, - Precedence::Override - ); - } - - #[test] - fn dep5_is_aggregate_precedence() { - 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 - ); - } - - #[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(), - 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(), - 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_updates_exact_path_in_place() { - 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. - assert_eq!( - write_annotation(root, "logo.png", "CC-BY-4.0", &cprs).unwrap(), - AnnotationWrite::Updated - ); - let text = std::fs::read_to_string(root.join("REUSE.toml")).unwrap(); - assert_eq!(text.matches("path = \"logo.png\"").count(), 1); - 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. - let mut oob = OutOfBand::default(); - oob.parse_reuse_toml(&text); - assert_eq!( - oob.lookup(&PathBuf::from("logo.png")).unwrap().license, - Some("CC-BY-4.0".to_string()) - ); - } - - #[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", &[]).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); - assert_eq!( - oob.lookup(&PathBuf::from("logo.png")).unwrap().license, - Some("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()), - "the glob still governs its other files" - ); - } - - #[test] - fn lookup_is_last_match() { - let mut oob = OutOfBand::default(); - oob.parse_reuse_toml( - "[[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()) - ); - } -} -// REUSE-IgnoreEnd diff --git a/src/reuse/oob/lookup.rs b/src/reuse/oob/lookup.rs new file mode 100644 index 0000000..7e10c3d --- /dev/null +++ b/src/reuse/oob/lookup.rs @@ -0,0 +1,216 @@ +//! 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('\\', "/"); + let (consulted, dep5_hit) = self.consult_tables(&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 fb = closest_fallbacks(&consulted); + if fb + .lic_origin + .as_ref() + .is_some_and(|o| Some(o) != fb.cpr_origin.as_ref()) + { + origins.push(fb.lic_origin.expect("checked")); + } + if let Some(o) = fb.cpr_origin { + origins.push(o); + } + if licenses.is_empty() + && 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), + // 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: 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() + } + + /// 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 + } +} + +/// 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) { + 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..f9aec43 --- /dev/null +++ b/src/reuse/oob/parse.rs @@ -0,0 +1,390 @@ +//! 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 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; + let paras = unfold_dep5_paragraphs(text); + 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(()) + } +} + +/// 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() == "." +} + +/// 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 + }; + // 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()), + } +} + +/// 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 { + // 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()), + } +} + +/// 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..7ecb76f --- /dev/null +++ b/src/reuse/oob/tests.rs @@ -0,0 +1,874 @@ +// 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 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 + // 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 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(); + 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..b4c51db --- /dev/null +++ b/src/reuse/oob/write.rs @@ -0,0 +1,399 @@ +//! 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); + } + 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 + // 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, + }) +} + +/// 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 { + 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/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/tool.rs b/src/tool.rs new file mode 100644 index 0000000..7e12585 --- /dev/null +++ b/src/tool.rs @@ -0,0 +1,172 @@ +//! 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); + } + + #[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(); + 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 { + name: "git".to_string(), + }; + let msg = err.to_string(); + assert!(msg.contains("`git`") && msg.contains("PATH"), "{msg}"); + } +} 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..f3c057c --- /dev/null +++ b/src/walk/git.rs @@ -0,0 +1,750 @@ +//! 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; +#[cfg(unix)] +use std::ffi::OsString; +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_binary()?) + .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_binary()?) + .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 want = self.prefetch_want_list(paths, extra); + 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_binary()?) + .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(()) + } + + /// 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. + 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()); + } + + #[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"), + ] + ); + } +} diff --git a/src/walk/mod.rs b/src/walk/mod.rs index 836a085..396fc74 100644 --- a/src/walk/mod.rs +++ b/src/walk/mod.rs @@ -1,24 +1,35 @@ -//! 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; 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 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::config::{CONFIG_FILENAME, has_legacy_only_config, legacy_config_error}; 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 +37,624 @@ 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() }; + assemble( + Assembly { + root, + source: crate::domain::ContentSource::Worktree, + snapshot, + universe, + expanded: false, + note: None, + }, + config_rel.as_deref(), + purpose, + allow_missing_config, + ) + } + Selection::Files(files) => { + let universe = normalize_file_list(&root, cwd, files)?; + let snapshot = Snapshot::Worktree { root: root.clone() }; + assemble( + Assembly { + root, + source: crate::domain::ContentSource::Worktree, + snapshot, + universe, + expanded: false, + note: None, + }, + config_rel.as_deref(), + purpose, + allow_missing_config, + ) + } + 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() }; + 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, + ); + } + // 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); + assemble( + Assembly { + root, + source: crate::domain::ContentSource::Index, + snapshot, + universe, + expanded, + note, + }, + config_rel.as_deref(), + purpose, + allow_missing_config, + ) + } 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() }; + assemble( + Assembly { + root, + source: crate::domain::ContentSource::Worktree, + snapshot, + universe, + expanded, + note, + }, + config_rel.as_deref(), + purpose, + allow_missing_config, + ) } + } +} + +/// 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). +fn resolve_config_rel(cwd: &Path, root: &Path, config_arg: &Path) -> Option { + // 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() { + config_arg.to_path_buf() + } else { + cwd.join(config_arg) }; + let norm = lexical_normalize(&abs); + comparison_spelling(&norm) + .strip_prefix(comparison_spelling(root)) + .map(|p| p.to_path_buf()) + .ok() +} - // 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 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(CONFIG_FILENAME), + }; + Ok(std::fs::read_to_string(path).unwrap_or_default()) } - let rel_str = rel.to_string_lossy().replace('\\', "/"); - let excluded = excludes.is_match(&rel_str); + 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(), + )), + }, + } +} + +/// 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( + 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() + )) + }, + ), + } +} + +/// 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()); + } + } + 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. +/// +/// 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 = comparison_spelling(&abs) + .strip_prefix(comparison_spelling(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) +} + +/// Spelling of `path` for prefix comparison against the discovered root. /// -/// 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> { +/// On Windows the process cwd can carry 8.3 short-name aliases +/// (`C:\Users\RUNNER~1\…`) while `git rev-parse` reports the long form +/// (`C:/Users/runneradmin/…`): a lexical `strip_prefix` then wrongly rejects +/// in-root explicit inputs as "outside the project root". Both spellings name +/// the same directory, so on Windows both sides compare through +/// `canonicalize` (spelling aliases resolved; a missing input falls back to +/// its nearest existing ancestor with the remainder reattached, else the raw +/// path). Unix keeps the lexical path untouched, preserving the +/// no-symlink-resolution contract there. +#[cfg(windows)] +fn comparison_spelling(path: &Path) -> 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 { + 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 +671,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 +691,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() - ))); - } - let paths = String::from_utf8_lossy(&output.stdout) - .lines() - .filter(|l| !l.trim().is_empty()) - .map(PathBuf::from) - .collect(); - Ok(paths) +/// 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); + } + builder + .build() + .map_err(|e| LicetError::Internal(format!("glob build: {e}"))) } #[cfg(test)] @@ -215,24 +710,60 @@ 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") + ); } + /// 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 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 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. + 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 +771,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..b9e15f8 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 { @@ -37,9 +66,20 @@ 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 + /// 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. @@ -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..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; @@ -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=[\"licet.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..f08e05b --- /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=[\"licet.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..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, 0 excluded, 0 unreadable +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_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..eeb17df 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(); @@ -46,8 +48,9 @@ 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"); let out = f.licet().args(["check"]).output().unwrap(); diff --git a/tests/us2_apply.rs b/tests/us2_apply.rs index 6346558..7a87a2c 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( + "licet.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( + "licet.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..d792ad3 --- /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=[\"licet.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=[\"licet.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=[\"licet.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=[\"licet.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=[\"licet.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..ad706b6 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=[\"licet.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=[\"licet.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=[\"licet.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=[\"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"] { + 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=[\"licet.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=[\"licet.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=[\"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()); + 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=[\"licet.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=[\"licet.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=[\"licet.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=[\"licet.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=[\"licet.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=[\"licet.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=[\"licet.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=[\"licet.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=[\"licet.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=[\"licet.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=[\"licet.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=[\"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"]) + .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=[\"licet.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 licet.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=[\"licet.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,326 @@ 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=[\"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"]) + .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 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", "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 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; + use std::process::Stdio; + let f = Fixture::new(); + 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. + 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..185903d 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] @@ -116,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!( @@ -127,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()); } @@ -150,4 +428,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..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!( @@ -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("licet.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("licet.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=[\"licet.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 licet.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", "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}"); + } +} + +#[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", "licet.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 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( + "licet.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("licet.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("licet.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("licet.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("licet.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("licet.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,