aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock1
-rw-r--r--crates/mozart-spdx-licenses/Cargo.toml3
-rw-r--r--crates/mozart-spdx-licenses/src/lib.rs29
-rw-r--r--crates/mozart/src/commands/validate.rs364
4 files changed, 371 insertions, 26 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 6fda00e..ea91bf6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1229,7 +1229,6 @@ version = "0.1.0"
name = "mozart-spdx-licenses"
version = "0.1.0"
dependencies = [
- "indexmap",
"serde",
"serde_json",
]
diff --git a/crates/mozart-spdx-licenses/Cargo.toml b/crates/mozart-spdx-licenses/Cargo.toml
index 1500932..7361c93 100644
--- a/crates/mozart-spdx-licenses/Cargo.toml
+++ b/crates/mozart-spdx-licenses/Cargo.toml
@@ -3,9 +3,6 @@ name = "mozart-spdx-licenses"
version.workspace = true
edition.workspace = true
-[dependencies]
-indexmap.workspace = true
-
[build-dependencies]
serde.workspace = true
serde_json.workspace = true
diff --git a/crates/mozart-spdx-licenses/src/lib.rs b/crates/mozart-spdx-licenses/src/lib.rs
index 668270f..a551988 100644
--- a/crates/mozart-spdx-licenses/src/lib.rs
+++ b/crates/mozart-spdx-licenses/src/lib.rs
@@ -1,4 +1,4 @@
-use indexmap::IndexMap;
+use std::collections::HashMap;
use std::sync::LazyLock;
include!(concat!(env!("OUT_DIR"), "/spdx_data.rs"));
@@ -21,16 +21,16 @@ pub struct ExceptionInfo {
/// SPDX license database with expression validation.
pub struct SpdxLicenses {
- licenses: IndexMap<&'static str, LicenseInfo>,
- exceptions: IndexMap<&'static str, ExceptionInfo>,
- name_to_id: IndexMap<&'static str, &'static str>,
+ licenses: HashMap<&'static str, LicenseInfo>,
+ exceptions: HashMap<&'static str, ExceptionInfo>,
+ name_to_id: HashMap<&'static str, &'static str>,
}
impl SpdxLicenses {
/// Build the license database from generated data.
pub fn new() -> Self {
- let mut licenses = IndexMap::with_capacity(LICENSES.len());
- let mut name_to_id = IndexMap::with_capacity(LICENSES.len());
+ let mut licenses = HashMap::with_capacity(LICENSES.len());
+ let mut name_to_id = HashMap::with_capacity(LICENSES.len());
for &(lower, id, full_name, osi, deprecated) in LICENSES {
licenses.insert(
lower,
@@ -44,7 +44,7 @@ impl SpdxLicenses {
name_to_id.insert(full_name, id);
}
- let mut exceptions = IndexMap::with_capacity(EXCEPTIONS.len());
+ let mut exceptions = HashMap::with_capacity(EXCEPTIONS.len());
for &(lower, id, full_name) in EXCEPTIONS {
exceptions.insert(
lower,
@@ -95,7 +95,6 @@ impl SpdxLicenses {
/// exceptions, the `+` (or-later) operator, LicenseRef, and the special
/// values `NONE` and `NOASSERTION`.
pub fn validate(&self, license: &str) -> bool {
- let license = license.trim();
if license.is_empty() {
return false;
}
@@ -418,6 +417,20 @@ mod tests {
}
#[test]
+ fn no_edge_whitespace_allowed() {
+ // Composer's `^(NONE|NOASSERTION|...)$` (with `x` flag) admits no
+ // leading or trailing whitespace; mirror that.
+ let db = spdx();
+ assert!(db.validate("MIT"));
+ assert!(!db.validate(" MIT"));
+ assert!(!db.validate("MIT "));
+ assert!(!db.validate(" MIT "));
+ assert!(!db.validate("\tMIT"));
+ assert!(!db.validate("MIT\t"));
+ assert!(!db.validate("\nMIT"));
+ }
+
+ #[test]
fn validate_list() {
let db = spdx();
assert!(db.validate_list(&["MIT", "Apache-2.0"]));
diff --git a/crates/mozart/src/commands/validate.rs b/crates/mozart/src/commands/validate.rs
index 061845e..fd7a534 100644
--- a/crates/mozart/src/commands/validate.rs
+++ b/crates/mozart/src/commands/validate.rs
@@ -269,35 +269,64 @@ fn check_name(obj: &serde_json::Map<String, serde_json::Value>, result: &mut Val
}
}
-/// Check the "license" field: warn if absent or empty, and warn on deprecated SPDX
-/// identifiers. Mirrors Composer's `Util\ConfigValidator::validate()`.
+/// Check the "license" field. Mirrors:
+/// * Composer's `Util\ConfigValidator::validate()` — "No license" + deprecation
+/// warnings (see fix B for these).
+/// * Composer's `Package\Loader\ValidatingArrayLoader::load()` license block —
+/// type-shape warnings, SPDX expression validity, and extra-spaces detection.
+/// The validity/extra-spaces checks are gated on the `time` field: only
+/// releases without a date or within the last 8 days are checked.
fn check_license(obj: &serde_json::Map<String, serde_json::Value>, result: &mut ValidationResult) {
let no_license_msg = "No license specified, it is recommended to do so. \
For closed-source software you may use \"proprietary\" as license."
.to_string();
// Composer's `empty($manifest['license'])` is true for missing, null, "", and [].
- let licenses: Vec<&str> = match obj.get("license") {
+ let raw_entries: Vec<&serde_json::Value> = match obj.get("license") {
None | Some(serde_json::Value::Null) => {
result.warnings.push(no_license_msg);
return;
}
- Some(serde_json::Value::String(s)) if s.is_empty() => {
- result.warnings.push(no_license_msg);
- return;
+ Some(v @ serde_json::Value::String(s)) => {
+ if s.is_empty() {
+ result.warnings.push(no_license_msg);
+ return;
+ }
+ vec![v]
}
- Some(serde_json::Value::Array(arr)) if arr.is_empty() => {
- result.warnings.push(no_license_msg);
+ Some(serde_json::Value::Array(arr)) => {
+ if arr.is_empty() {
+ result.warnings.push(no_license_msg);
+ return;
+ }
+ arr.iter().collect()
+ }
+ // ValidatingArrayLoader: license must be a string or array of strings.
+ Some(other) => {
+ result.warnings.push(format!(
+ "License must be a string or array of strings, got {}.",
+ serde_json::to_string(other).unwrap_or_default()
+ ));
return;
}
- Some(serde_json::Value::String(s)) => vec![s.as_str()],
- Some(serde_json::Value::Array(arr)) => arr.iter().filter_map(|v| v.as_str()).collect(),
- // Non-string, non-array — schema validation handles the type error elsewhere.
- Some(_) => return,
};
- for license in licenses {
- if license == "proprietary" {
+ // ValidatingArrayLoader: each entry must be a string. Non-string entries
+ // are dropped from the working set with a per-entry warning.
+ let mut licenses: Vec<&str> = Vec::with_capacity(raw_entries.len());
+ for v in raw_entries {
+ match v.as_str() {
+ Some(s) => licenses.push(s),
+ None => result.warnings.push(format!(
+ "License {} should be a string.",
+ serde_json::to_string(v).unwrap_or_default()
+ )),
+ }
+ }
+
+ // ConfigValidator: deprecated identifier warnings.
+ for license in &licenses {
+ if *license == "proprietary" {
continue;
}
if !mozart_core::validation::is_license_deprecated(license) {
@@ -319,6 +348,126 @@ fn check_license(obj: &serde_json::Map<String, serde_json::Value>, result: &mut
};
result.warnings.push(warning);
}
+
+ // ValidatingArrayLoader: SPDX expression validity, gated on the 8-day
+ // release window. If `time` is absent or unparseable, releaseDate is
+ // treated as null (PHP `try/catch` semantics) → validation runs.
+ let release_ts = obj
+ .get("time")
+ .and_then(|v| v.as_str())
+ .and_then(parse_iso_time_to_unix);
+ let cutoff = current_unix_time().saturating_sub(8 * 86_400);
+ let in_window = release_ts.is_none_or(|ts| ts >= cutoff);
+ if !in_window {
+ return;
+ }
+ for license in &licenses {
+ if *license == "proprietary" {
+ continue;
+ }
+ let to_validate = license.replace("proprietary", "MIT");
+ if mozart_core::validation::validate_license(&to_validate) {
+ continue;
+ }
+ // PHP `json_encode($license)` for a string is `"escaped"`; serde matches.
+ let quoted = serde_json::to_string(license).unwrap_or_else(|_| format!("\"{license}\""));
+ if mozart_core::validation::validate_license(to_validate.trim()) {
+ result.warnings.push(format!(
+ "License {quoted} must not contain extra spaces, make sure to trim it."
+ ));
+ } else {
+ result.warnings.push(format!(
+ "License {quoted} is not a valid SPDX license identifier, see https://spdx.org/licenses/ if you use an open license.\n\
+ If the software is closed-source, you may use \"proprietary\" as license."
+ ));
+ }
+ }
+}
+
+/// Current time as a Unix timestamp (UTC seconds since epoch). 0 if the
+/// system clock is set before the epoch.
+fn current_unix_time() -> i64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_secs() as i64)
+ .unwrap_or(0)
+}
+
+/// Parse a Composer-style `time` string into a Unix timestamp.
+///
+/// Accepts `YYYY-MM-DD HH:MM:SS` (Composer's typical output) and
+/// `YYYY-MM-DDTHH:MM:SS` with an optional `Z` or numeric offset suffix. The
+/// timezone suffix is parsed when present; absent suffixes are treated as
+/// UTC, matching `new DateTime($time, new DateTimeZone('UTC'))`.
+fn parse_iso_time_to_unix(s: &str) -> Option<i64> {
+ let bytes = s.as_bytes();
+ if bytes.len() < 19 {
+ return None;
+ }
+ let n = |start: usize, len: usize| -> Option<i64> {
+ std::str::from_utf8(&bytes[start..start + len])
+ .ok()?
+ .parse()
+ .ok()
+ };
+ let year = n(0, 4)? as i32;
+ if bytes[4] != b'-' {
+ return None;
+ }
+ let month = n(5, 2)? as i32;
+ if bytes[7] != b'-' {
+ return None;
+ }
+ let day = n(8, 2)? as i32;
+ if bytes[10] != b' ' && bytes[10] != b'T' {
+ return None;
+ }
+ let hour = n(11, 2)?;
+ if bytes[13] != b':' {
+ return None;
+ }
+ let minute = n(14, 2)?;
+ if bytes[16] != b':' {
+ return None;
+ }
+ let second = n(17, 2)?;
+
+ // Optional timezone suffix.
+ let mut tz_offset_seconds: i64 = 0;
+ if bytes.len() > 19 {
+ let suffix = &s[19..];
+ if suffix == "Z" {
+ tz_offset_seconds = 0;
+ } else {
+ let body = suffix
+ .strip_prefix('+')
+ .or_else(|| suffix.strip_prefix('-'))?;
+ let sign = if suffix.starts_with('+') { 1 } else { -1 };
+ let body: String = body.chars().filter(|c| *c != ':').collect();
+ if body.len() < 4 {
+ return None;
+ }
+ let oh: i64 = body.get(0..2)?.parse().ok()?;
+ let om: i64 = body.get(2..4)?.parse().ok()?;
+ tz_offset_seconds = sign * (oh * 3600 + om * 60);
+ }
+ }
+
+ let utc = days_from_civil(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second
+ - tz_offset_seconds;
+ Some(utc)
+}
+
+/// Howard Hinnant's `days_from_civil`: returns days since 1970-01-01 for a
+/// proleptic Gregorian (year, month, day). Handles negative years correctly.
+fn days_from_civil(y: i32, m: i32, d: i32) -> i64 {
+ let y = if m <= 2 { y - 1 } else { y };
+ let era = if y >= 0 { y / 400 } else { (y - 399) / 400 };
+ let yoe = (y - era * 400) as i64; // [0, 399]
+ let m_adj = if m > 2 { m - 3 } else { m + 9 };
+ let doy = (153 * m_adj as i64 + 2) / 5 + d as i64 - 1;
+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
+ (era as i64) * 146_097 + doe - 719_468
}
/// Warn if the "version" field is present.
@@ -960,6 +1109,193 @@ mod tests {
);
}
+ // ── ValidatingArrayLoader-equivalent license checks ────────────────────
+
+ #[test]
+ fn test_validate_license_wrong_type_warns() {
+ let json = r#"{"name": "vendor/pkg", "license": 42}"#;
+ let result = parse_and_validate(json, &make_args());
+ assert!(
+ result.warnings.iter().any(|w| w
+ .contains("License must be a string or array of strings")
+ && w.contains("42")),
+ "got: {:?}",
+ result.warnings
+ );
+ }
+
+ #[test]
+ fn test_validate_license_array_non_string_entry_warns() {
+ let json = r#"{"name": "vendor/pkg", "license": ["MIT", 42]}"#;
+ let result = parse_and_validate(json, &make_args());
+ assert!(
+ result
+ .warnings
+ .iter()
+ .any(|w| w.contains("License 42 should be a string")),
+ "got: {:?}",
+ result.warnings
+ );
+ }
+
+ #[test]
+ fn test_validate_invalid_spdx_license_warns() {
+ let json = r#"{"name": "vendor/pkg", "license": "totally-not-a-license"}"#;
+ let result = parse_and_validate(json, &make_args());
+ assert!(
+ result
+ .warnings
+ .iter()
+ .any(|w| w.contains("\"totally-not-a-license\"")
+ && w.contains("not a valid SPDX license identifier")),
+ "got: {:?}",
+ result.warnings
+ );
+ }
+
+ #[test]
+ fn test_validate_license_extra_spaces_warns() {
+ // Composer's expression regex tolerates one ASCII space on each end —
+ // the warning only fires when there are more (or non-space whitespace).
+ let json = r#"{"name": "vendor/pkg", "license": " MIT "}"#;
+ let result = parse_and_validate(json, &make_args());
+ assert!(
+ result
+ .warnings
+ .iter()
+ .any(|w| w.contains("must not contain extra spaces")),
+ "got: {:?}",
+ result.warnings
+ );
+ assert!(
+ !result
+ .warnings
+ .iter()
+ .any(|w| w.contains("not a valid SPDX")),
+ "extra-spaces case should not also emit invalid-SPDX, got: {:?}",
+ result.warnings
+ );
+ }
+
+ #[test]
+ fn test_validate_license_proprietary_in_expression_validates() {
+ // Composer replaces "proprietary" with "MIT" before validating, so
+ // expressions mixing "proprietary" with real identifiers are accepted.
+ let json = r#"{"name": "vendor/pkg", "license": "(MIT OR proprietary)"}"#;
+ let result = parse_and_validate(json, &make_args());
+ assert!(
+ !result
+ .warnings
+ .iter()
+ .any(|w| w.contains("not a valid SPDX")),
+ "got: {:?}",
+ result.warnings
+ );
+ }
+
+ #[test]
+ fn test_validate_license_old_release_skips_validity_check() {
+ // Release older than 8 days → SPDX validity check is skipped, but
+ // deprecation warnings (ConfigValidator's path) still fire.
+ let json = r#"{
+ "name": "vendor/pkg",
+ "license": "totally-not-a-license",
+ "time": "1970-01-01 00:00:00"
+ }"#;
+ let result = parse_and_validate(json, &make_args());
+ assert!(
+ !result
+ .warnings
+ .iter()
+ .any(|w| w.contains("not a valid SPDX")),
+ "old release should not produce SPDX validity warning, got: {:?}",
+ result.warnings
+ );
+ }
+
+ #[test]
+ fn test_validate_license_recent_release_validates() {
+ // A future "time" is within the cutoff (>= now-8days) → validate.
+ let json = r#"{
+ "name": "vendor/pkg",
+ "license": "totally-not-a-license",
+ "time": "9999-01-01 00:00:00"
+ }"#;
+ let result = parse_and_validate(json, &make_args());
+ assert!(
+ result
+ .warnings
+ .iter()
+ .any(|w| w.contains("not a valid SPDX")),
+ "recent release should produce SPDX validity warning, got: {:?}",
+ result.warnings
+ );
+ }
+
+ #[test]
+ fn test_validate_license_unparseable_time_treated_as_null() {
+ // Unparseable time → releaseDate stays null → validation runs.
+ let json = r#"{
+ "name": "vendor/pkg",
+ "license": "totally-not-a-license",
+ "time": "not-a-date"
+ }"#;
+ let result = parse_and_validate(json, &make_args());
+ assert!(
+ result
+ .warnings
+ .iter()
+ .any(|w| w.contains("not a valid SPDX")),
+ "unparseable time should be treated as null → validate, got: {:?}",
+ result.warnings
+ );
+ }
+
+ // ── parse_iso_time_to_unix ─────────────────────────────────────────────
+
+ #[test]
+ fn test_parse_iso_time_basic() {
+ assert_eq!(parse_iso_time_to_unix("1970-01-01 00:00:00"), Some(0));
+ assert_eq!(
+ parse_iso_time_to_unix("2023-12-15 13:45:30"),
+ Some(1_702_647_930)
+ );
+ }
+
+ #[test]
+ fn test_parse_iso_time_t_separator() {
+ assert_eq!(
+ parse_iso_time_to_unix("2023-12-15T13:45:30"),
+ Some(1_702_647_930)
+ );
+ assert_eq!(
+ parse_iso_time_to_unix("2023-12-15T13:45:30Z"),
+ Some(1_702_647_930)
+ );
+ }
+
+ #[test]
+ fn test_parse_iso_time_with_offset() {
+ // 13:45:30+05:00 = 08:45:30 UTC
+ assert_eq!(
+ parse_iso_time_to_unix("2023-12-15T13:45:30+05:00"),
+ Some(1_702_647_930 - 5 * 3600)
+ );
+ // 13:45:30-05:00 = 18:45:30 UTC
+ assert_eq!(
+ parse_iso_time_to_unix("2023-12-15T13:45:30-05:00"),
+ Some(1_702_647_930 + 5 * 3600)
+ );
+ }
+
+ #[test]
+ fn test_parse_iso_time_invalid() {
+ assert_eq!(parse_iso_time_to_unix(""), None);
+ assert_eq!(parse_iso_time_to_unix("not-a-date"), None);
+ assert_eq!(parse_iso_time_to_unix("2023-12-15"), None);
+ assert_eq!(parse_iso_time_to_unix("2023/12/15 13:45:30"), None);
+ }
+
// ── check_version_field ────────────────────────────────────────────────
#[test]