diff --git a/benches/scan.rs b/benches/scan.rs index 1b9cca6..10d7532 100644 --- a/benches/scan.rs +++ b/benches/scan.rs @@ -6,6 +6,7 @@ //! the reference runner; here we watch for regressions in relative throughput. //! //! Run with `cargo bench`; results land under `target/criterion/`. +// REUSE-IgnoreStart — SPDX tags below are test fixtures, not this file's licensing. use std::path::Path; @@ -74,3 +75,4 @@ fn bench_scan(c: &mut Criterion) { criterion_group!(benches, bench_scan); criterion_main!(benches); +// REUSE-IgnoreEnd diff --git a/docs/REUSE_Specification_v3.3.md b/docs/REUSE_Specification_v3.3.md index 59069c5..081302f 100644 --- a/docs/REUSE_Specification_v3.3.md +++ b/docs/REUSE_Specification_v3.3.md @@ -1,3 +1,4 @@ + # REUSE Specification – Version 3.3 > 2024-11-14 @@ -269,4 +270,5 @@ SPDX-SnippetCopyrightText: (C) Example Cooperative © Example Corporation Copyright 2016, 2018-2019 Joe Anybody Copyright (c) Alice, some rights reserved -``` \ No newline at end of file +``` + diff --git a/mise.toml b/mise.toml index 9bf359d..b399ea5 100644 --- a/mise.toml +++ b/mise.toml @@ -3,7 +3,7 @@ python = "3.13.14" rust = "nightly" git-cliff = "latest" cargo-release = "latest" -'cargo:licet' = "0.1.4" +"cargo:licet" = "0.2.1" [tools.'pipx:reuse'] version = "latest" diff --git a/src/cli/init.rs b/src/cli/init.rs index b17bb1b..141e7be 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -92,17 +92,76 @@ pub fn run(args: InitArgs) -> Result { Ok(ExitCode::Success) } +/// Serializable shape of the generated `license.toml`. Emitted via the `toml` crate so any +/// license id or extension is correctly quoted/escaped — hand-rolled `"{d}"` interpolation +/// previously produced invalid TOML for values containing quotes, backslashes, or newlines. +#[derive(serde::Serialize)] +struct GeneratedConfig { + #[serde(skip_serializing_if = "Option::is_none")] + default: Option, + #[serde(rename = "rule", skip_serializing_if = "Vec::is_empty")] + rules: Vec, +} + +#[derive(serde::Serialize)] +struct DefaultSection { + license: String, +} + +#[derive(serde::Serialize)] +struct RuleSection { + ext: String, + license: String, +} + fn render_config(default: Option<&str>, rules: &[(String, String)]) -> String { - let mut out = String::new(); - out.push_str("# Generated by `licet init` from existing repository state.\n\n"); - if let Some(d) = default { - out.push_str("[default]\n"); - out.push_str(&format!("license = \"{d}\"\n\n")); + let config = GeneratedConfig { + default: default.map(|d| DefaultSection { + license: d.to_string(), + }), + rules: rules + .iter() + .map(|(ext, lic)| RuleSection { + ext: ext.clone(), + license: lic.clone(), + }) + .collect(), + }; + // This flat, string-only shape always serializes; fall back to an empty body rather + // than panicking if that ever changes. + let body = toml::to_string(&config).unwrap_or_default(); + format!("# Generated by `licet init` from existing repository state.\n\n{body}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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())], + ); + toml::from_str::(&toml) + .expect("generated output must be syntactically valid TOML"); } - for (ext, lic) in rules { - out.push_str("[[rule]]\n"); - out.push_str(&format!("ext = \"{ext}\"\n")); - out.push_str(&format!("license = \"{lic}\"\n\n")); + + #[test] + fn render_is_well_formed_for_ordinary_input() { + let toml = render_config(Some("MIT"), &[("rs".to_string(), "Apache-2.0".to_string())]); + assert!( + toml.contains("[default]") && toml.contains("license = \"MIT\""), + "{toml}" + ); + assert!( + toml.contains("[[rule]]") && toml.contains("ext = \"rs\""), + "{toml}" + ); + LicensingConfiguration::from_toml(&toml).expect("ordinary config must parse"); } - out } diff --git a/src/detect/mod.rs b/src/detect/mod.rs index fbebda8..5883a04 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -23,6 +23,7 @@ use crate::domain::{ 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. @@ -306,12 +307,18 @@ fn parse_headers(text: &str) -> ParsedFile { continue; } - let lic = lic_at.map(|i| trim_value(&raw[i..])); + // Accept the value only when it is a well-formed SPDX expression. The tag is matched + // 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()); if in_snippet { // Snippet licensing is gathered for inventory only — never file-level. if let Some(l) = lic { - snippet_licenses.push(l.trim().to_string()); + snippet_licenses.push(l); } continue; } @@ -330,7 +337,7 @@ fn parse_headers(text: &str) -> ParsedFile { }); block.byte_range.1 = end; if let Some(l) = lic { - block.license_ids.push(l.trim().to_string()); + block.license_ids.push(l); } if let Some(c) = cpr { block.copyrights.push(c.trim().to_string()); @@ -491,6 +498,24 @@ mod tests { assert_eq!(h[0].license_ids, vec!["CC0-1.0".to_string()]); } + #[test] + fn non_spdx_tag_value_is_not_captured_as_license() { + // The tag is matched anywhere on a line, so a tag appearing inside a source-string + // literal (e.g. this project's own test fixtures) trails non-SPDX junk after the id. + // Such a value must not be accepted as the file's license (would otherwise poison + // `init`'s generated config — regression for the unescaped-TOML crash). + let parsed = + parse_headers(" f.write(\"a.py\", \"# SPDX-License-Identifier: MIT\\nx=1\\n\")\n"); + assert!( + parsed.blocks.iter().all(|b| b.license_ids.is_empty()), + "malformed SPDX expression must not be detected: {:?}", + parsed.blocks + ); + // A clean id on its own line is still detected. + let ok = parse_headers("# SPDX-License-Identifier: MIT\n").blocks; + assert_eq!(ok[0].license_ids, vec!["MIT".to_string()]); + } + #[test] fn block_closers_across_styles_are_trimmed() { // One representative per registry block style; the trailing closer must be stripped diff --git a/src/reconcile/insert.rs b/src/reconcile/insert.rs index 79f2bf3..c62758e 100644 --- a/src/reconcile/insert.rs +++ b/src/reconcile/insert.rs @@ -93,6 +93,7 @@ pub fn insert_header(content: &str, header: &str) -> String { out } +// REUSE-IgnoreStart — SPDX tags in the tests below are fixtures, not this file's licensing. #[cfg(test)] mod tests { use super::*; @@ -137,3 +138,4 @@ mod tests { ); } } +// REUSE-IgnoreEnd diff --git a/src/reconcile/mod.rs b/src/reconcile/mod.rs index 0761898..fee4e2a 100644 --- a/src/reconcile/mod.rs +++ b/src/reconcile/mod.rs @@ -178,6 +178,7 @@ fn extract_terminator(value: &str) -> String { String::new() } +// REUSE-IgnoreStart — SPDX tags in the tests below are fixtures, not this file's licensing. #[cfg(test)] mod tests { use super::*; @@ -288,3 +289,4 @@ mod tests { assert!(plan.new_content.is_none()); } } +// REUSE-IgnoreEnd diff --git a/src/reuse/oob.rs b/src/reuse/oob.rs index 6a748be..873f817 100644 --- a/src/reuse/oob.rs +++ b/src/reuse/oob.rs @@ -362,6 +362,7 @@ 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::*; @@ -518,3 +519,4 @@ mod tests { ); } } +// REUSE-IgnoreEnd diff --git a/tests/determinism.rs b/tests/determinism.rs index c8e2295..2c6e932 100644 --- a/tests/determinism.rs +++ b/tests/determinism.rs @@ -5,6 +5,7 @@ //! 3. The binary makes no network calls; `--allow-network` is advisory only. (The absence //! of any network-capable crate in the dependency tree is the structural guarantee — //! asserted in CI by `cargo tree`; here we confirm the offline paths behave identically.) +// REUSE-IgnoreStart — SPDX tags below are test fixtures, not this file's licensing. mod common; use common::Fixture; @@ -92,3 +93,4 @@ fn allow_network_flag_does_not_change_offline_behavior() { "JSON lint posture must be identical regardless of --allow-network (no fetch occurs)" ); } +// REUSE-IgnoreEnd diff --git a/tests/perf.rs b/tests/perf.rs index c8f0678..223dde4 100644 --- a/tests/perf.rs +++ b/tests/perf.rs @@ -2,6 +2,7 @@ //! well within budget. This is a coarse timing guard (not a microbenchmark) sized to be //! stable in CI; the spec's 10k-file <1s warm / <3s cold bars are validated on the //! reference 4-core runner. Here we assert a generous ceiling to catch gross regressions. +// REUSE-IgnoreStart — SPDX tags below are test fixtures, not this file's licensing. mod common; use common::Fixture; @@ -33,3 +34,4 @@ fn scans_a_few_thousand_files_quickly() { ); eprintln!("cold scan of {n} files: {cold:?}"); } +// REUSE-IgnoreEnd diff --git a/tests/safety.rs b/tests/safety.rs index 53c6d39..5aa3aff 100644 --- a/tests/safety.rs +++ b/tests/safety.rs @@ -1,5 +1,6 @@ //! Apply-safety, config-error, and detection-precedence suites //! (FR-024, SC-010, FR-003a, config validation → exit 2). +// REUSE-IgnoreStart — SPDX tags below are test fixtures, not this file's licensing. mod common; use common::Fixture; @@ -129,3 +130,4 @@ fn line_endings_preserved_on_write() { "CRLF preserved: {content:?}" ); } +// REUSE-IgnoreEnd diff --git a/tests/us1_check_drift.rs b/tests/us1_check_drift.rs index 8500f11..5a02162 100644 --- a/tests/us1_check_drift.rs +++ b/tests/us1_check_drift.rs @@ -1,4 +1,5 @@ //! US1 — declarative drift detection (SC-001, SC-003, FR-004). Mirrors quickstart Scenario 1. +// REUSE-IgnoreStart — SPDX tags below are test fixtures, not this file's licensing. mod common; use common::Fixture; @@ -133,3 +134,4 @@ fn semantic_expression_equivalence_is_compliant() { String::from_utf8_lossy(&out.stdout) ); } +// REUSE-IgnoreEnd diff --git a/tests/us1_snapshots.rs b/tests/us1_snapshots.rs index 8ec5f0a..2963573 100644 --- a/tests/us1_snapshots.rs +++ b/tests/us1_snapshots.rs @@ -2,6 +2,7 @@ //! These pin the exact rendered text of `licet check` so layout regressions are caught. //! File ordering is deterministic (walk sorts by relative path), and all paths are //! repo-relative, so the snapshots are stable across machines/tempdirs. +// REUSE-IgnoreStart — SPDX tags below are test fixtures, not this file's licensing. mod common; use common::Fixture; @@ -54,3 +55,4 @@ fn compliant_report_human() { let stdout = String::from_utf8(out.stdout).unwrap(); insta::assert_snapshot!(stdout); } +// REUSE-IgnoreEnd diff --git a/tests/us2_apply.rs b/tests/us2_apply.rs index 9cb29a0..6346558 100644 --- a/tests/us2_apply.rs +++ b/tests/us2_apply.rs @@ -1,4 +1,5 @@ //! US2 — reconcile to intent (SC-002, SC-004, FR-006..FR-009, FR-020). Quickstart Scenario 2. +// REUSE-IgnoreStart — SPDX tags below are test fixtures, not this file's licensing. mod common; use common::Fixture; @@ -120,3 +121,4 @@ fn target_header_replaces_chosen_block() { ); assert!(!content.contains("ISC"), "second block replaced: {content}"); } +// REUSE-IgnoreEnd diff --git a/tests/us2_partial.rs b/tests/us2_partial.rs index f4859e7..0a98518 100644 --- a/tests/us2_partial.rs +++ b/tests/us2_partial.rs @@ -1,5 +1,6 @@ //! US2 — partial apply (FR-021): when some writes succeed and others fail, exit 3 and //! report changed vs unchanged files. +// REUSE-IgnoreStart — SPDX tags below are test fixtures, not this file's licensing. #![cfg(unix)] @@ -46,3 +47,4 @@ fn partial_apply_exits_3_and_reports_changed_vs_unchanged() { ); assert!(f.read("ok/a.rs").contains("SPDX-License-Identifier: MIT")); } +// REUSE-IgnoreEnd diff --git a/tests/us3_comment_style.rs b/tests/us3_comment_style.rs index 4f5f2a2..da2ff64 100644 --- a/tests/us3_comment_style.rs +++ b/tests/us3_comment_style.rs @@ -1,5 +1,6 @@ //! US3 — new comment style persists & round-trips (SC-005, FR-010, FR-011). //! Quickstart Scenario 3. +// REUSE-IgnoreStart — SPDX tags below are test fixtures, not this file's licensing. mod common; use common::Fixture; @@ -66,3 +67,4 @@ fn inline_custom_style_is_used() { "{content}" ); } +// REUSE-IgnoreEnd diff --git a/tests/us4_enforce.rs b/tests/us4_enforce.rs index 1ff7384..8bb2955 100644 --- a/tests/us4_enforce.rs +++ b/tests/us4_enforce.rs @@ -1,5 +1,6 @@ //! US4 — enforce in hook / CI (SC-006, SC-009, FR-013, FR-025, FR-027). //! Quickstart Scenario 4. +// REUSE-IgnoreStart — SPDX tags below are test fixtures, not this file's licensing. mod common; use common::Fixture; @@ -84,3 +85,4 @@ fn explain_names_winning_rule() { let stdout = String::from_utf8_lossy(&out.stdout); assert!(stdout.contains("glob=examples/**/*.rs"), "{stdout}"); } +// REUSE-IgnoreEnd diff --git a/tests/us5_add_license.rs b/tests/us5_add_license.rs index 0f52c7d..2737cb6 100644 --- a/tests/us5_add_license.rs +++ b/tests/us5_add_license.rs @@ -1,6 +1,7 @@ //! US5 — `add-license` offline text materialization (FR-017, FR-029). //! The offline analog of `reuse download`: copy referenced texts into `LICENSES/` from the //! embedded bundle, without touching source files or the config. Quickstart Scenario 5. +// REUSE-IgnoreStart — SPDX tags below are test fixtures, not this file's licensing. mod common; use common::Fixture; @@ -149,3 +150,4 @@ fn json_output_is_well_formed() { assert_eq!(v["summary"]["pass"], true); assert!(v["license_texts"]["spdx_list_version"].is_string()); } +// REUSE-IgnoreEnd diff --git a/tests/us5_reuse.rs b/tests/us5_reuse.rs index b674079..578c54c 100644 --- a/tests/us5_reuse.rs +++ b/tests/us5_reuse.rs @@ -1,5 +1,6 @@ //! US5 — REUSE compatibility & migration (SC-007, SC-008, FR-014, FR-017, FR-018, FR-028). //! Quickstart Scenario 5. +// REUSE-IgnoreStart — SPDX tags below are test fixtures, not this file's licensing. mod common; use common::Fixture; @@ -100,3 +101,4 @@ fn which_reuse() -> Option<()> { .filter(|o| o.status.success()) .map(|_| ()) } +// REUSE-IgnoreEnd diff --git a/tests/us5_sidecars.rs b/tests/us5_sidecars.rs index b3dd7d8..4ee244d 100644 --- a/tests/us5_sidecars.rs +++ b/tests/us5_sidecars.rs @@ -1,5 +1,6 @@ //! `.license` sidecars and REUSE.toml coverage for non-annotatable files (FR-015), //! plus REUSE 3.3 precedence on detection (FR-003a). +// REUSE-IgnoreStart — SPDX tags below are test fixtures, not this file's licensing. mod common; @@ -238,3 +239,4 @@ 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); } +// REUSE-IgnoreEnd diff --git a/tests/us5_snippets.rs b/tests/us5_snippets.rs index 7904df5..a14bde9 100644 --- a/tests/us5_snippets.rs +++ b/tests/us5_snippets.rs @@ -2,6 +2,7 @@ //! describes the snippet, not the file, so it never satisfies (or violates) the file's //! declared intent — but its text still counts for `LICENSES/` completeness. Information //! inside `REUSE-IgnoreStart`..`REUSE-IgnoreEnd` is dropped entirely. +// REUSE-IgnoreStart — SPDX tags below are test fixtures, not this file's licensing. mod common; @@ -100,3 +101,4 @@ fn ignore_block_hides_spdx_tags() { 1 ); } +// REUSE-IgnoreEnd