diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-04 14:41:59 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-04 15:43:56 +0900 |
| commit | a24d6e2f148417b32188cd1e643439a2858f4eac (patch) | |
| tree | 593bbb830c6d46a33a40465dbd16fda98082543d /crates/mozart/src/commands | |
| parent | c59a923669c57adbf6e5eecce7feae59afcf0aac (diff) | |
| download | php-mozart-a24d6e2f148417b32188cd1e643439a2858f4eac.tar.gz php-mozart-a24d6e2f148417b32188cd1e643439a2858f4eac.tar.zst php-mozart-a24d6e2f148417b32188cd1e643439a2858f4eac.zip | |
feat(validate): port ValidatingArrayLoader license checks
Mirror Composer's Package\Loader\ValidatingArrayLoader::load() license
block: warn on non-string/wrong-shape values, validate the SPDX
expression with proprietary→MIT substitution, and surface "extra
spaces" diagnostics. Validity is gated on the manifest's `time` field
(checked only for releases without a date or within the last 8 days),
mirroring Composer's strtotime('-8days') window.
Diffstat (limited to 'crates/mozart/src/commands')
| -rw-r--r-- | crates/mozart/src/commands/validate.rs | 364 |
1 files changed, 350 insertions, 14 deletions
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] |
