Skip to content

Commit 0f559aa

Browse files
mstykowclaude
andauthored
fix(license): canonicalize LicenseRef-scancode SPDX keys to mirror license keys (#1223)
Upstream ScanCode enforces an arbitrary "spdx_license_key must be 50 characters or less" lint that squashed or truncated dozens of LicenseRef-scancode-* SPDX keys (e.g. LicenseRef-scancode-openssl-exception-lgpl3.0plus instead of the canonical ...lgpl-3.0-plus; see aboutcode-org/scancode-toolkit#5221). For the LicenseRef-scancode namespace the identifier is by definition the license key with the namespace prefix, so any deviation is a distortion, not a semantic choice. Provenant applies no length limit, so restore the canonical form at index-build time and keep the previous value in other_spdx_license_keys for backward compatibility. An audit of all 2733 licenses found 50 such deviations: 49 are canonicalized by the general rule; 1 (tgc-spec-license-v2) carries an upstream typo in the license *key* while its SPDX key is already correct (tcg), so it is exempted from the pass (mirroring the key would regress the correct SPDX key, and renaming the key would drop a ScanCode-compatible identifier with no alias mechanism). Signed-off-by: Maxim Stykow <maxim.stykow@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 49c11fe commit 0f559aa

7 files changed

Lines changed: 278 additions & 1 deletion

File tree

docs/LICENSE_DETECTION_ARCHITECTURE.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,19 @@ instead of minting a tool-specific variant. This applies across detection output
7575
fallbacks such as `unknown-spdx`, and parser-side declared-license normalization for keys such as
7676
`public-domain`, `proprietary-license`, and `unknown-license-reference`.
7777

78+
Because a `LicenseRef-scancode-<key>` identifier is by definition the license `key` with the
79+
namespace prefix, the identifier must mirror the key. Upstream ScanCode enforces an arbitrary
80+
"spdx_license_key must be 50 characters or less" lint, which forced dozens of keys to be squashed
81+
or truncated (for example `LicenseRef-scancode-openssl-exception-lgpl3.0plus` instead of the
82+
canonical `LicenseRef-scancode-openssl-exception-lgpl-3.0-plus`; see ScanCode PR
83+
[#5221](https://github.com/aboutcode-org/scancode-toolkit/pull/5221)). Provenant applies no such
84+
length limit, so `spdx_key_canonicalization` restores the canonical form at index-build time and
85+
keeps the previous value in `other_spdx_license_keys` for backward compatibility. The rare case
86+
where the license `key` itself is misspelled (the SPDX key already being correct) is exempted by an
87+
explicit, justified entry: mirroring the key would regress the correct SPDX key, so the entry is
88+
left exactly as upstream has it (the license key stays ScanCode-compatible and its SPDX key stays
89+
right).
90+
7891
Parser-side declared-license normalization also maps bare, informal license names that are not
7992
valid SPDX but resolve to a license by strong convention (for example `Apache`, `PSF`, `Python`).
8093
These mappings live in a curated config, `resources/license_detection/declared_license_aliases.toml`,
@@ -141,6 +154,7 @@ The loading process is split into two distinct stages:
141154
- Apply the checked-in license-index build policy
142155
- Apply any checked-in downstream overlay files from `resources/license_detection/overlay/`
143156
- Fail fast if an ignore id no longer exists upstream, an overlay-reason entry is stale or missing, or an overlay file becomes identical to upstream data
157+
- Canonicalize `LicenseRef-scancode-*` SPDX keys so each mirrors its license key (see the `LicenseRef-*` namespace convention above)
144158
- Sort embedded rules and licenses deterministically
145159
- Serialize the embedded loader snapshot with MessagePack
146160
- Compress the serialized bytes with zstd
-1.63 KB
Binary file not shown.

src/license_detection/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pub mod models;
2929
pub mod query;
3030
pub mod rules;
3131
pub mod seq_match;
32+
pub mod spdx_key_canonicalization;
3233
pub mod spdx_lid;
3334
pub mod spdx_mapping;
3435
#[cfg(test)]

src/license_detection/models/loaded_license.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ use serde::{Deserialize, Serialize};
2323
/// This struct contains parsed and normalized data from a .LICENSE file.
2424
/// It is serialized at build time and deserialized at runtime, then converted
2525
/// to a runtime `License` during the build stage.
26-
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26+
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2727
pub struct LoadedLicense {
2828
pub key: String,
2929
pub short_name: Option<String>,
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
// SPDX-FileCopyrightText: Provenant contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Build-time canonicalization of `LicenseRef-scancode-*` SPDX keys.
5+
//!
6+
//! Upstream ScanCode enforces an (arbitrary) "spdx_license_key must be 50
7+
//! characters or less" lint in `licensedcode/models.py`. For licenses in the
8+
//! ScanCode `LicenseRef-scancode-<key>` namespace this forced dozens of keys to
9+
//! be squashed or truncated (for example
10+
//! `LicenseRef-scancode-openssl-exception-lgpl3.0plus` instead of the canonical
11+
//! `LicenseRef-scancode-openssl-exception-lgpl-3.0-plus`), see ScanCode PR
12+
//! aboutcode-org/scancode-toolkit#5221.
13+
//!
14+
//! For the `LicenseRef-scancode-` namespace the SPDX key is by definition the
15+
//! license `key` with the namespace prefix, so any deviation is a distortion,
16+
//! not a semantic choice. Provenant applies no length limit, so this pass
17+
//! restores the canonical form at index-build time and keeps the previous value
18+
//! in `other_spdx_license_keys` for backward compatibility.
19+
//!
20+
//! A small set of licenses carry a typo in the license `key` itself while their
21+
//! `spdx_license_key` is already correct. There, mirroring the key would
22+
//! *regress* the correct SPDX key, so those keys are exempted from this pass.
23+
//! The license key is left exactly as upstream has it (ScanCode parity), which
24+
//! is harmless because renaming a license key is a foreign-identity change with
25+
//! no backward-compatible alias mechanism, and the SPDX key users consume is
26+
//! already right.
27+
28+
use crate::license_detection::models::LoadedLicense;
29+
30+
/// The ScanCode SPDX LicenseRef namespace prefix.
31+
const SCANCODE_LICENSEREF_PREFIX: &str = "LicenseRef-scancode-";
32+
33+
/// License keys exempted from canonicalization because the upstream typo is in
34+
/// the license `key`, not the SPDX key. Mirroring the key would regress an
35+
/// already-correct `spdx_license_key`, so the entry is left untouched.
36+
///
37+
/// Each entry must be justified.
38+
const SPDX_CANONICALIZATION_EXEMPT_KEYS: &[&str] = &[
39+
// "TCG" = Trusted Computing Group (see the license owner/holders). The key
40+
// misspells it as "tgc" while spdx_license_key already uses the correct
41+
// "tcg" (LicenseRef-scancode-tcg-spec-license-v2). Canonicalizing would
42+
// rewrite that correct SPDX key into the typo, so leave it as upstream has
43+
// it; the license key stays ScanCode-compatible and the SPDX key stays right.
44+
"tgc-spec-license-v2",
45+
];
46+
47+
/// A single SPDX key that was canonicalized to mirror the license `key`.
48+
#[derive(Debug, Clone, PartialEq, Eq)]
49+
pub struct SpdxKeyCanonicalization {
50+
pub license_key: String,
51+
pub previous_spdx_license_key: String,
52+
pub canonical_spdx_license_key: String,
53+
}
54+
55+
/// Summary of the changes applied by [`canonicalize_license_spdx_keys`].
56+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
57+
pub struct SpdxKeyCanonicalizationReport {
58+
pub canonicalized_spdx_keys: Vec<SpdxKeyCanonicalization>,
59+
}
60+
61+
impl SpdxKeyCanonicalizationReport {
62+
pub fn is_empty(&self) -> bool {
63+
self.canonicalized_spdx_keys.is_empty()
64+
}
65+
}
66+
67+
/// Canonicalize `LicenseRef-scancode-*` SPDX keys in place so the suffix mirrors
68+
/// each license `key`.
69+
///
70+
/// This is a build-time curation step: it runs when the embedded license index
71+
/// artifact is generated, so the corrected values are baked into the artifact
72+
/// and flow through the SPDX mapping, license references, and SPDX output.
73+
pub fn canonicalize_license_spdx_keys(
74+
licenses: &mut [LoadedLicense],
75+
) -> SpdxKeyCanonicalizationReport {
76+
let mut report = SpdxKeyCanonicalizationReport::default();
77+
78+
for license in licenses.iter_mut() {
79+
if SPDX_CANONICALIZATION_EXEMPT_KEYS.contains(&license.key.as_str()) {
80+
continue;
81+
}
82+
83+
let Some(spdx) = license.spdx_license_key.as_deref() else {
84+
continue;
85+
};
86+
if !spdx.starts_with(SCANCODE_LICENSEREF_PREFIX) {
87+
continue;
88+
}
89+
90+
let canonical = format!("{SCANCODE_LICENSEREF_PREFIX}{}", license.key);
91+
if spdx == canonical {
92+
continue;
93+
}
94+
95+
let previous = spdx.to_string();
96+
97+
// Drop the canonical form from the alias list to avoid duplicating the
98+
// new primary, and preserve the previous primary as a backward-compatible
99+
// alias.
100+
license
101+
.other_spdx_license_keys
102+
.retain(|k| k != &canonical && k != &previous);
103+
license.other_spdx_license_keys.push(previous.clone());
104+
105+
license.spdx_license_key = Some(canonical.clone());
106+
report
107+
.canonicalized_spdx_keys
108+
.push(SpdxKeyCanonicalization {
109+
license_key: license.key.clone(),
110+
previous_spdx_license_key: previous,
111+
canonical_spdx_license_key: canonical,
112+
});
113+
}
114+
115+
report
116+
}
117+
118+
#[cfg(test)]
119+
mod tests {
120+
use super::*;
121+
122+
fn license(key: &str, spdx: Option<&str>, other: &[&str]) -> LoadedLicense {
123+
LoadedLicense {
124+
key: key.to_string(),
125+
spdx_license_key: spdx.map(str::to_string),
126+
other_spdx_license_keys: other.iter().map(|s| s.to_string()).collect(),
127+
..Default::default()
128+
}
129+
}
130+
131+
#[test]
132+
fn promotes_canonical_form_and_demotes_squashed_primary() {
133+
// The openssl-exception-lgpl-3.0-plus case from ScanCode PR #5221.
134+
let mut licenses = vec![license(
135+
"openssl-exception-lgpl-3.0-plus",
136+
Some("LicenseRef-scancode-openssl-exception-lgpl3.0plus"),
137+
&["LicenseRef-scancode-openssl-exception-lgpl-3.0-plus"],
138+
)];
139+
140+
let report = canonicalize_license_spdx_keys(&mut licenses);
141+
142+
assert_eq!(
143+
licenses[0].spdx_license_key.as_deref(),
144+
Some("LicenseRef-scancode-openssl-exception-lgpl-3.0-plus")
145+
);
146+
assert_eq!(
147+
licenses[0].other_spdx_license_keys,
148+
vec!["LicenseRef-scancode-openssl-exception-lgpl3.0plus".to_string()]
149+
);
150+
assert_eq!(report.canonicalized_spdx_keys.len(), 1);
151+
}
152+
153+
#[test]
154+
fn adds_previous_primary_when_canonical_not_already_present() {
155+
// Truncation case (gradle-enterprise-sla-2022-11-08): canonical is not in
156+
// the alias list yet.
157+
let mut licenses = vec![license(
158+
"gradle-enterprise-sla-2022-11-08",
159+
Some("LicenseRef-scancode-gradle-enterprise-sla-2022-11-"),
160+
&[],
161+
)];
162+
163+
canonicalize_license_spdx_keys(&mut licenses);
164+
165+
assert_eq!(
166+
licenses[0].spdx_license_key.as_deref(),
167+
Some("LicenseRef-scancode-gradle-enterprise-sla-2022-11-08")
168+
);
169+
assert_eq!(
170+
licenses[0].other_spdx_license_keys,
171+
vec!["LicenseRef-scancode-gradle-enterprise-sla-2022-11-".to_string()]
172+
);
173+
}
174+
175+
#[test]
176+
fn exempts_key_typo_so_correct_spdx_key_is_not_regressed() {
177+
// tgc-spec-license-v2: the key is the typo, the SPDX key is already
178+
// correct. The pass must leave both fields untouched (no rename, no SPDX
179+
// regression).
180+
let mut licenses = vec![license(
181+
"tgc-spec-license-v2",
182+
Some("LicenseRef-scancode-tcg-spec-license-v2"),
183+
&[],
184+
)];
185+
186+
let report = canonicalize_license_spdx_keys(&mut licenses);
187+
188+
assert_eq!(licenses[0].key, "tgc-spec-license-v2");
189+
assert_eq!(
190+
licenses[0].spdx_license_key.as_deref(),
191+
Some("LicenseRef-scancode-tcg-spec-license-v2"),
192+
"correct SPDX key must be preserved, not regressed to the typo"
193+
);
194+
assert!(licenses[0].other_spdx_license_keys.is_empty());
195+
assert!(report.is_empty());
196+
}
197+
198+
#[test]
199+
fn leaves_canonical_and_real_spdx_keys_untouched() {
200+
let mut licenses = vec![
201+
license("mit", Some("MIT"), &[]),
202+
license(
203+
"some-ref",
204+
Some("LicenseRef-scancode-some-ref"),
205+
&["LicenseRef-scancode-old-alias"],
206+
),
207+
license("no-spdx", None, &[]),
208+
];
209+
210+
let report = canonicalize_license_spdx_keys(&mut licenses);
211+
212+
assert!(report.is_empty());
213+
assert_eq!(licenses[0].spdx_license_key.as_deref(), Some("MIT"));
214+
assert_eq!(
215+
licenses[1].other_spdx_license_keys,
216+
vec!["LicenseRef-scancode-old-alias".to_string()]
217+
);
218+
}
219+
}

src/license_detection/tests.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,37 @@ fn test_engine_spdx_mapping() {
428428
);
429429
}
430430

431+
#[test]
432+
fn test_embedded_licenseref_spdx_keys_are_canonicalized() {
433+
let engine = get_engine();
434+
let mapping = engine.spdx_mapping();
435+
436+
// The squashed 50-char-limit form is restored to the canonical dashed form
437+
// in the embedded artifact (ScanCode PR #5221 and the wider audit).
438+
assert_eq!(
439+
mapping
440+
.scancode_to_spdx("openssl-exception-lgpl-3.0-plus")
441+
.as_deref(),
442+
Some("LicenseRef-scancode-openssl-exception-lgpl-3.0-plus"),
443+
);
444+
assert_eq!(
445+
mapping.scancode_to_spdx("bash-exception-gpl").as_deref(),
446+
Some("LicenseRef-scancode-bash-exception-gpl"),
447+
);
448+
449+
// The `tgc-spec-license-v2` key carries an upstream typo, but its SPDX key is
450+
// already correct (`tcg`). It is exempted from canonicalization so the key
451+
// stays ScanCode-compatible and the correct SPDX key is not regressed.
452+
assert_eq!(
453+
mapping.scancode_to_spdx("tgc-spec-license-v2").as_deref(),
454+
Some("LicenseRef-scancode-tcg-spec-license-v2"),
455+
);
456+
assert!(
457+
mapping.scancode_to_spdx("tcg-spec-license-v2").is_none(),
458+
"the license key is left as upstream has it; no renamed key is introduced",
459+
);
460+
}
461+
431462
#[test]
432463
fn test_engine_detect_no_license() {
433464
let engine = get_engine();

xtask/src/bin/generate_index_artifact.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use provenant::license_detection::embedded::schema::{EmbeddedLoaderSnapshot, SCH
1717
use provenant::license_detection::rules::{
1818
load_loaded_licenses_from_directory, load_loaded_rules_from_directory,
1919
};
20+
use provenant::license_detection::spdx_key_canonicalization::canonicalize_license_spdx_keys;
2021

2122
#[derive(Parser, Debug)]
2223
#[command(
@@ -62,6 +63,17 @@ fn main() -> Result<()> {
6263
apply_default_index_build_policy(loaded_rules, loaded_licenses)?;
6364
loaded_rules = filtered_rules;
6465
loaded_licenses = filtered_licenses;
66+
67+
// Restore canonical LicenseRef-scancode-<key> SPDX keys that upstream squashed
68+
// to satisfy its 50-character lint (see spdx_key_canonicalization docs).
69+
let spdx_canonicalization = canonicalize_license_spdx_keys(&mut loaded_licenses);
70+
if !spdx_canonicalization.is_empty() {
71+
println!(
72+
"Canonicalized {} LicenseRef-scancode SPDX keys",
73+
spdx_canonicalization.canonicalized_spdx_keys.len()
74+
);
75+
}
76+
6577
let dataset_fingerprint = compute_dataset_fingerprint_string(&loaded_rules, &loaded_licenses)?;
6678
let license_index_provenance = policy_report
6779
.to_license_index_provenance(EMBEDDED_LICENSE_INDEX_SOURCE, dataset_fingerprint);

0 commit comments

Comments
 (0)