Skip to main content

vivacity_resolver/
lockfile.rs

1//! Lock writing: port of `ArrayDumper::dump` (from the raw metadata and the
2//! model, with `ArrayLoader`'s normalizations), of `Locker::lockPackages`
3//! and of `Locker::setLockData`; JsonFile encoding.
4
5use crate::package::Package;
6use crate::root::RootAlias;
7use crate::version::DEFAULT_BRANCH_ALIAS;
8use serde_json::{Map, Value};
9use std::cmp::Ordering;
10use std::sync::OnceLock;
11
12/// PHP `empty()`.
13pub fn php_empty(v: Option<&Value>) -> bool {
14    match v {
15        None | Some(Value::Null) | Some(Value::Bool(false)) => true,
16        Some(Value::String(s)) => s.is_empty() || s == "0",
17        Some(Value::Number(n)) => n.as_f64() == Some(0.0),
18        Some(Value::Array(a)) => a.is_empty(),
19        Some(Value::Object(o)) => o.is_empty(),
20        Some(Value::Bool(true)) => false,
21    }
22}
23
24/// PHP `is_array` on JSON decoded as an associative array.
25fn is_array(v: &Value) -> bool {
26    matches!(v, Value::Array(_) | Value::Object(_))
27}
28
29/// PHP 8 comparison of two strings (`sort`/`strcmp`: numeric strings
30/// compare as numbers, the others byte by byte).
31pub fn php_compare_strings(a: &str, b: &str) -> Ordering {
32    fn numeric(s: &str) -> Option<f64> {
33        let t = s.trim_start_matches([' ', '\t', '\n', '\r', '\x0b', '\x0c']);
34        let t = t.trim_end_matches([' ', '\t', '\n', '\r', '\x0b', '\x0c']);
35        if t.is_empty() {
36            return None;
37        }
38        let bytes = t.as_bytes();
39        let ok = bytes
40            .iter()
41            .all(|c| c.is_ascii_digit() || matches!(c, b'.' | b'e' | b'E' | b'+' | b'-'));
42        if !ok || !bytes[0].is_ascii_digit() && !matches!(bytes[0], b'.' | b'+' | b'-') {
43            return None;
44        }
45        t.parse::<f64>().ok()
46    }
47    match (numeric(a), numeric(b)) {
48        (Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
49        _ => a.as_bytes().cmp(b.as_bytes()),
50    }
51}
52
53/// Stable insertion sort (zend_insert_sort for n <= 16; beyond that
54/// zend_sort becomes hybrid and a non-total order may differ).
55fn insertion_sort<T>(items: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
56    for i in 1..items.len() {
57        let mut j = i;
58        while j > 0 && cmp(&items[j - 1], &items[j]) == Ordering::Greater {
59            items.swap(j - 1, j);
60            j -= 1;
61        }
62    }
63}
64
65/// `Package::getTargetDir()`.
66fn normalize_target_dir(dir: &str) -> String {
67    static RE: OnceLock<pcre2::bytes::Regex> = OnceLock::new();
68    let re = crate::version::regex(
69        &RE,
70        r"(?:^|[\\/]+)\.\.?(?:[\\/]+|$)(?:\.\.?(?:[\\/]+|$))*",
71        false,
72    );
73    let mut out = String::new();
74    let mut last = 0;
75    for m in re.find_iter(dir.as_bytes()).flatten() {
76        out.push_str(&dir[last..m.start()]);
77        out.push('/');
78        last = m.end();
79    }
80    out.push_str(&dir[last..]);
81    out.trim_start_matches('/').to_owned()
82}
83
84fn ksort(map: &Map<String, Value>) -> Map<String, Value> {
85    let mut entries: Vec<(&String, &Value)> = map.iter().collect();
86    insertion_sort(&mut entries, |(a, _), (b, _)| php_compare_strings(a, b));
87    entries
88        .into_iter()
89        .map(|(k, v)| (k.clone(), v.clone()))
90        .collect()
91}
92
93/// `ArrayLoader`: `new \DateTime($time, UTC)` then `format(DATE_RFC3339)`.
94/// Recognized forms: integer timestamp, ISO 8601 with or without timezone,
95/// `Y-m-d H:i:s`, `Y-m-d`. An unknown form is ignored (exception swallowed).
96pub fn release_date(time: &Value) -> Option<String> {
97    let text = match time {
98        Value::String(s) => s.clone(),
99        Value::Number(n) => n.to_string(),
100        _ => return None,
101    };
102    if php_empty(Some(time)) {
103        return None;
104    }
105    if text.bytes().all(|c| c.is_ascii_digit()) {
106        let ts: i64 = text.parse().ok()?;
107        return Some(format_rfc3339(civil_from_unix(ts), "+00:00"));
108    }
109    parse_datetime(&text)
110}
111
112/// (year, month, day, hour, minute, second).
113type Civil = (i64, u32, u32, u32, u32, u32);
114
115fn civil_from_unix(ts: i64) -> Civil {
116    let days = ts.div_euclid(86_400);
117    let secs = ts.rem_euclid(86_400);
118    // Howard Hinnant's algorithm (days -> civil).
119    let z = days + 719_468;
120    let era = z.div_euclid(146_097);
121    let doe = z.rem_euclid(146_097);
122    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
123    let y = yoe + era * 400;
124    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
125    let mp = (5 * doy + 2) / 153;
126    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
127    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
128    let y = if m <= 2 { y + 1 } else { y };
129    (
130        y,
131        m,
132        d,
133        (secs / 3600) as u32,
134        ((secs % 3600) / 60) as u32,
135        (secs % 60) as u32,
136    )
137}
138
139fn format_rfc3339(c: Civil, offset: &str) -> String {
140    format!(
141        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{offset}",
142        c.0, c.1, c.2, c.3, c.4, c.5
143    )
144}
145
146fn parse_datetime(text: &str) -> Option<String> {
147    let t = text.trim();
148    let b = t.as_bytes();
149    let num = |i: usize, n: usize| -> Option<u32> {
150        if b.len() < i + n || !b[i..i + n].iter().all(u8::is_ascii_digit) {
151            return None;
152        }
153        t[i..i + n].parse().ok()
154    };
155    let year = num(0, 4)? as i64;
156    if b.get(4) != Some(&b'-') || b.get(7) != Some(&b'-') {
157        return None;
158    }
159    let month = num(5, 2)?;
160    let day = num(8, 2)?;
161    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
162        return None;
163    }
164    let mut pos = 10;
165    let (mut h, mut mi, mut s) = (0, 0, 0);
166    if let Some(&sep) = b.get(pos) {
167        if sep != b'T' && sep != b' ' {
168            return None;
169        }
170        pos += 1;
171        h = num(pos, 2)?;
172        if b.get(pos + 2) != Some(&b':') {
173            return None;
174        }
175        mi = num(pos + 3, 2)?;
176        pos += 5;
177        if b.get(pos) == Some(&b':') {
178            s = num(pos + 1, 2)?;
179            pos += 3;
180        }
181        if h > 23 || mi > 59 || s > 60 {
182            return None;
183        }
184        // Fractional seconds are ignored by the output format.
185        if b.get(pos) == Some(&b'.') {
186            pos += 1;
187            while b.get(pos).is_some_and(u8::is_ascii_digit) {
188                pos += 1;
189            }
190        }
191    }
192    let rest = t[pos..].trim_start();
193    let offset = match rest {
194        "" => "+00:00".to_owned(),
195        "Z" | "z" | "UTC" | "GMT" => "+00:00".to_owned(),
196        r if (r.starts_with('+') || r.starts_with('-')) && r.len() >= 3 && r.len() <= 6 => {
197            let sign = &r[..1];
198            let digits: String = r[1..].chars().filter(|c| *c != ':').collect();
199            if !digits.bytes().all(|c| c.is_ascii_digit()) {
200                return None;
201            }
202            let (oh, om) = match digits.len() {
203                2 => (digits.parse::<u32>().ok()?, 0),
204                4 => (
205                    digits[..2].parse::<u32>().ok()?,
206                    digits[2..].parse::<u32>().ok()?,
207                ),
208                _ => return None,
209            };
210            format!("{sign}{oh:02}:{om:02}")
211        }
212        _ => return None,
213    };
214    Some(format_rfc3339((year, month, day, h, mi, s), &offset))
215}
216
217/// `ArrayDumper::dump($package)` for a non-root package.
218pub fn dump_package(p: &Package) -> Map<String, Value> {
219    let raw = p.raw.as_object().cloned().unwrap_or_default();
220    let mut data = Map::new();
221    data.insert("name".into(), Value::String(p.pretty_name.clone()));
222    data.insert("version".into(), Value::String(p.pretty_version.clone()));
223    data.insert(
224        "version_normalized".into(),
225        Value::String(p.version.clone()),
226    );
227    if let Some(td) = raw.get("target-dir").filter(|v| !v.is_null()) {
228        // `Package::getTargetDir()`: `.`/`..` segments and leading slashes
229        // removed.
230        let text = match td {
231            Value::String(s) => s.clone(),
232            other => other.to_string(),
233        };
234        data.insert(
235            "target-dir".into(),
236            Value::String(normalize_target_dir(&text)),
237        );
238    }
239    if let Some(src) = &p.source {
240        let mut s = Map::new();
241        s.insert("type".into(), Value::String(src.kind.clone()));
242        s.insert("url".into(), Value::String(src.url.clone()));
243        if let Some(r) = &src.reference {
244            s.insert("reference".into(), Value::String(r.clone()));
245        }
246        if let Some(m) = raw.get("source").and_then(|s| s.get("mirrors")) {
247            if !php_empty(Some(m)) {
248                s.insert("mirrors".into(), m.clone());
249            }
250        }
251        data.insert("source".into(), Value::Object(s));
252    }
253    if let Some(dist) = &p.dist {
254        let mut d = Map::new();
255        d.insert("type".into(), Value::String(dist.kind.clone()));
256        // `setDistUrl('')` stores null.
257        d.insert(
258            "url".into(),
259            if dist.url.is_empty() {
260                Value::Null
261            } else {
262                Value::String(dist.url.clone())
263            },
264        );
265        if let Some(r) = &dist.reference {
266            d.insert("reference".into(), Value::String(r.clone()));
267        }
268        if let Some(shasum) = raw.get("dist").and_then(|s| s.get("shasum")) {
269            if !shasum.is_null() {
270                d.insert("shasum".into(), shasum.clone());
271            }
272        }
273        if let Some(m) = raw.get("dist").and_then(|s| s.get("mirrors")) {
274            if !php_empty(Some(m)) {
275                d.insert("mirrors".into(), m.clone());
276            }
277        }
278        data.insert("dist".into(), Value::Object(d));
279    }
280    for (key, links) in [
281        ("require", &p.requires),
282        ("conflict", &p.conflicts),
283        ("provide", &p.provides),
284        ("replace", &p.replaces),
285        ("require-dev", &p.dev_requires),
286    ] {
287        if links.is_empty() {
288            continue;
289        }
290        let mut m = Map::new();
291        for l in links.iter() {
292            m.insert(l.target.clone(), Value::String(l.pretty_constraint.clone()));
293        }
294        data.insert(key.into(), Value::Object(ksort(&m)));
295    }
296    if let Some(suggest) = raw.get("suggest").filter(|v| is_array(v)) {
297        let self_version = |v: &Value| match v {
298            Value::String(s) if s.trim() == "self.version" => {
299                Value::String(p.pretty_version.clone())
300            }
301            other => other.clone(),
302        };
303        let value = match suggest {
304            Value::Object(o) => {
305                let m: Map<String, Value> = o
306                    .iter()
307                    .map(|(k, v)| (k.clone(), self_version(v)))
308                    .collect();
309                Value::Object(ksort(&m))
310            }
311            Value::Array(a) => Value::Array(a.iter().map(self_version).collect()),
312            _ => Value::Null,
313        };
314        if !php_empty(Some(&value)) {
315            data.insert("suggest".into(), value);
316        }
317    }
318    if let Some(time) = raw.get("time").and_then(release_date) {
319        data.insert("time".into(), Value::String(time));
320    }
321    if p.is_default_branch {
322        data.insert("default-branch".into(), Value::Bool(true));
323    }
324    // dumpValues: bin, type, extra, installation-source, autoload,
325    // autoload-dev, notification-url, include-path, php-ext.
326    if let Some(bin) = raw.get("bin").filter(|v| !v.is_null()) {
327        // PHP array: an object's keys are preserved.
328        let ltrim = |v: Value| match v {
329            Value::String(s) => Value::String(s.trim_start_matches('/').to_owned()),
330            other => other,
331        };
332        let value = match bin {
333            Value::Array(a) => Value::Array(a.iter().cloned().map(ltrim).collect()),
334            Value::Object(o) => Value::Object(
335                o.iter()
336                    .map(|(k, v)| (k.clone(), ltrim(v.clone())))
337                    .collect(),
338            ),
339            other => Value::Array(vec![ltrim(other.clone())]),
340        };
341        if !php_empty(Some(&value)) {
342            data.insert("bin".into(), value);
343        }
344    }
345    // `getType()`: `$this->type ?: 'library'`.
346    data.insert(
347        "type".into(),
348        Value::String(if p.package_type.is_empty() || p.package_type == "0" {
349            "library".to_owned()
350        } else {
351            p.package_type.clone()
352        }),
353    );
354    if let Some(extra) = raw.get("extra") {
355        if is_array(extra) && !php_empty(Some(extra)) {
356            data.insert("extra".into(), extra.clone());
357        }
358    }
359    if let Some(v) = raw.get("installation-source").filter(|v| !v.is_null()) {
360        data.insert("installation-source".into(), v.clone());
361    }
362    for key in ["autoload", "autoload-dev"] {
363        if let Some(v) = raw.get(key) {
364            if is_array(v) && !php_empty(Some(v)) {
365                data.insert(key.into(), v.clone());
366            }
367        }
368    }
369    if let Some(v) = raw.get("notification-url") {
370        if !php_empty(Some(v)) {
371            data.insert("notification-url".into(), v.clone());
372        }
373    }
374    if let Some(v) = raw.get("include-path") {
375        if is_array(v) && !php_empty(Some(v)) {
376            data.insert("include-path".into(), v.clone());
377        }
378    }
379    if let Some(v) = raw.get("php-ext") {
380        if is_array(v) && !php_empty(Some(v)) {
381            data.insert("php-ext".into(), v.clone());
382        }
383    }
384    // CompletePackage.
385    let mut archive = Map::new();
386    if let Some(name) = raw.get("archive").and_then(|a| a.get("name")) {
387        if !php_empty(Some(name)) {
388            archive.insert("name".into(), name.clone());
389        }
390    }
391    if let Some(exclude) = raw.get("archive").and_then(|a| a.get("exclude")) {
392        if !php_empty(Some(exclude)) {
393            archive.insert("exclude".into(), exclude.clone());
394        }
395    }
396    if !archive.is_empty() {
397        data.insert("archive".into(), Value::Object(archive));
398    }
399    if let Some(scripts) = raw.get("scripts").filter(|v| is_array(v)) {
400        // `(array) $listeners`: an array keeps its keys, a scalar is
401        // wrapped, null becomes empty.
402        let cast = |listeners: &Value| match listeners {
403            Value::Array(_) | Value::Object(_) => listeners.clone(),
404            Value::Null => Value::Array(Vec::new()),
405            other => Value::Array(vec![other.clone()]),
406        };
407        let value = match scripts {
408            Value::Object(o) => {
409                Value::Object(o.iter().map(|(k, v)| (k.clone(), cast(v))).collect())
410            }
411            Value::Array(a) => Value::Array(a.iter().map(cast).collect()),
412            _ => Value::Null,
413        };
414        if !php_empty(Some(&value)) {
415            data.insert("scripts".into(), value);
416        }
417    }
418    if let Some(license) = raw.get("license") {
419        if !php_empty(Some(license)) {
420            let list = match license {
421                Value::Array(a) => Value::Array(a.clone()),
422                Value::Object(o) => Value::Object(o.clone()),
423                other => Value::Array(vec![other.clone()]),
424            };
425            data.insert("license".into(), list);
426        }
427    }
428    if let Some(authors) = raw.get("authors") {
429        if is_array(authors) && !php_empty(Some(authors)) {
430            data.insert("authors".into(), authors.clone());
431        }
432    }
433    if let Some(Value::String(d)) = raw.get("description") {
434        if !d.is_empty() && d != "0" {
435            data.insert("description".into(), Value::String(d.clone()));
436        }
437    }
438    if let Some(Value::String(h)) = raw.get("homepage") {
439        if !h.is_empty() && h != "0" {
440            data.insert("homepage".into(), Value::String(h.clone()));
441        }
442    }
443    if let Some(keywords) = raw.get("keywords") {
444        if is_array(keywords) && !php_empty(Some(keywords)) {
445            let list: Vec<Value> = match keywords {
446                Value::Array(a) => a.clone(),
447                Value::Object(o) => o.values().cloned().collect(),
448                _ => Vec::new(),
449            };
450            let mut strings: Vec<String> = list
451                .iter()
452                .map(|v| match v {
453                    Value::String(s) => s.clone(),
454                    Value::Bool(true) => "1".to_owned(),
455                    Value::Bool(false) | Value::Null => String::new(),
456                    Value::Number(n) => match n.as_f64() {
457                        Some(f) if n.is_f64() && f.fract() == 0.0 && f.abs() < 1e15 => {
458                            format!("{}", f as i64)
459                        }
460                        _ => n.to_string(),
461                    },
462                    other => other.to_string(),
463                })
464                .collect();
465            // `sort()`: the PHP comparison is not a total order on mixed
466            // strings; an insertion sort (zend_sort's below 17 elements)
467            // assumes nothing and does not panic.
468            insertion_sort(&mut strings, |a, b| php_compare_strings(a, b));
469            data.insert(
470                "keywords".into(),
471                Value::Array(strings.into_iter().map(Value::String).collect()),
472            );
473        }
474    }
475    if let Some(support) = raw.get("support") {
476        if is_array(support) && !php_empty(Some(support)) {
477            data.insert("support".into(), support.clone());
478        }
479    }
480    if let Some(funding) = raw.get("funding") {
481        if is_array(funding) && !php_empty(Some(funding)) {
482            data.insert("funding".into(), funding.clone());
483        }
484    }
485    match raw.get("abandoned") {
486        Some(Value::String(s)) if !s.is_empty() && s != "0" => {
487            data.insert("abandoned".into(), Value::String(s.clone()));
488        }
489        Some(v) if !php_empty(Some(v)) && !v.is_string() => {
490            data.insert("abandoned".into(), Value::Bool(true));
491        }
492        _ => {}
493    }
494    // `transport-options`: those of the lock (`loadOptions`) or set by the
495    // repository (`configurePackageTransportOptions`); the repository has
496    // already stripped those of the metadata.
497    if let Some(t) = raw.get("transport-options") {
498        if is_array(t) && !php_empty(Some(t)) {
499            data.insert("transport-options".into(), t.clone());
500        }
501    }
502    data
503}
504
505/// `Locker::lockPackages`.
506pub fn lock_packages(arena: &[Package], packages: &[usize]) -> Result<Vec<Value>, String> {
507    let mut locked: Vec<Map<String, Value>> = Vec::new();
508    for &idx in packages {
509        let p = &arena[idx];
510        if p.is_alias() {
511            continue;
512        }
513        if php_empty(Some(&Value::String(p.pretty_name.clone())))
514            || php_empty(Some(&Value::String(p.pretty_version.clone())))
515        {
516            return Err(format!(
517                "Package \"{}\" has no version or name and can not be locked",
518                p.pretty_string()
519            ));
520        }
521        let mut spec = dump_package(p);
522        spec.shift_remove("version_normalized");
523        let time = spec.shift_remove("time");
524        if let Some(t) = time {
525            spec.insert("time".into(), t);
526        }
527        spec.shift_remove("installation-source");
528        locked.push(spec);
529    }
530    locked.sort_by(|a, b| {
531        let (an, bn) = (
532            a["name"].as_str().unwrap_or(""),
533            b["name"].as_str().unwrap_or(""),
534        );
535        an.as_bytes().cmp(bn.as_bytes()).then_with(|| {
536            a["version"]
537                .as_str()
538                .unwrap_or("")
539                .as_bytes()
540                .cmp(b["version"].as_str().unwrap_or("").as_bytes())
541        })
542    });
543    Ok(locked.into_iter().map(Value::Object).collect())
544}
545
546pub struct LockInput<'a> {
547    pub content_hash: &'a str,
548    pub packages: Vec<Value>,
549    pub packages_dev: Option<Vec<Value>>,
550    pub platform: Map<String, Value>,
551    pub platform_dev: Map<String, Value>,
552    pub aliases: &'a [RootAlias],
553    pub minimum_stability: &'a str,
554    pub stability_flags: &'a std::collections::BTreeMap<String, i32>,
555    pub prefer_stable: bool,
556    pub prefer_lowest: bool,
557    pub platform_overrides: &'a Map<String, Value>,
558}
559
560/// `Locker::setLockData`: the lock data (before writing).
561pub fn lock_data(input: LockInput<'_>) -> Value {
562    let aliases: Vec<Value> = input
563        .aliases
564        .iter()
565        .map(|a| {
566            let version =
567                if ["dev-master", "dev-trunk", "dev-default"].contains(&a.version.as_str()) {
568                    DEFAULT_BRANCH_ALIAS.to_owned()
569                } else {
570                    a.version.clone()
571                };
572            let mut m = Map::new();
573            m.insert("package".into(), Value::String(a.package.clone()));
574            m.insert("version".into(), Value::String(version));
575            m.insert("alias".into(), Value::String(a.alias.clone()));
576            m.insert(
577                "alias_normalized".into(),
578                Value::String(a.alias_normalized.clone()),
579            );
580            Value::Object(m)
581        })
582        .collect();
583    let mut lock = Map::new();
584    lock.insert(
585        "_readme".into(),
586        Value::Array(vec![
587            Value::String("This file locks the dependencies of your project to a known state".into()),
588            Value::String(
589                "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies".into(),
590            ),
591            Value::String("This file is @generated automatically".into()),
592        ]),
593    );
594    lock.insert(
595        "content-hash".into(),
596        Value::String(input.content_hash.to_owned()),
597    );
598    lock.insert("packages".into(), Value::Array(input.packages));
599    lock.insert(
600        "packages-dev".into(),
601        match input.packages_dev {
602            Some(p) => Value::Array(p),
603            None => Value::Null,
604        },
605    );
606    lock.insert("aliases".into(), Value::Array(aliases));
607    lock.insert(
608        "minimum-stability".into(),
609        Value::String(input.minimum_stability.to_owned()),
610    );
611    // fixupJsonDataType: ksort + `{}` if empty.
612    let mut flags = Map::new();
613    for (k, v) in input.stability_flags {
614        flags.insert(k.clone(), Value::Number((*v).into()));
615    }
616    let object_or_stdclass = |m: Map<String, Value>| -> Value {
617        if m.is_empty() {
618            vivacity_core::phpjson::empty_stdclass()
619        } else {
620            Value::Object(m)
621        }
622    };
623    lock.insert("stability-flags".into(), object_or_stdclass(ksort(&flags)));
624    lock.insert("prefer-stable".into(), Value::Bool(input.prefer_stable));
625    lock.insert("prefer-lowest".into(), Value::Bool(input.prefer_lowest));
626    lock.insert("platform".into(), object_or_stdclass(input.platform));
627    lock.insert(
628        "platform-dev".into(),
629        object_or_stdclass(input.platform_dev),
630    );
631    if !input.platform_overrides.is_empty() {
632        lock.insert(
633            "platform-overrides".into(),
634            Value::Object(input.platform_overrides.clone()),
635        );
636    }
637    lock.insert(
638        "plugin-api-version".into(),
639        Value::String(crate::platform::PLUGIN_API_VERSION.to_owned()),
640    );
641    Value::Object(lock)
642}
643
644/// `ValidatingArrayLoader::validatePackage` (called on every package kept
645/// by the solver): rejects invalid or reserved names, URLs/references
646/// starting with `-` (argument injection) and `bin` entries containing
647/// `..`.
648pub fn validate_package(p: &Package) -> Result<(), String> {
649    static DASH: OnceLock<pcre2::bytes::Regex> = OnceLock::new();
650    static DOTDOT: OnceLock<pcre2::bytes::Regex> = OnceLock::new();
651    if matches!(p.origin, crate::package::Origin::Root) {
652        return Ok(());
653    }
654    if let Some(err) = package_naming_error(&p.name, false) {
655        return Err(format!(
656            "Invalid package found during dependency resolution, aborting: {err}"
657        ));
658    }
659    let dash = crate::version::regex(&DASH, r"^\s*-", false);
660    let fields: [(&str, Option<&str>); 4] = [
661        ("source.url", p.source.as_ref().map(|s| s.url.as_str())),
662        (
663            "source.reference",
664            p.source.as_ref().and_then(|s| s.reference.as_deref()),
665        ),
666        (
667            "dist.url",
668            p.dist
669                .as_ref()
670                .map(|d| d.url.as_str())
671                .filter(|u| !u.is_empty()),
672        ),
673        (
674            "dist.reference",
675            p.dist.as_ref().and_then(|d| d.reference.as_deref()),
676        ),
677    ];
678    for (field, value) in fields {
679        if let Some(v) = value {
680            if dash.is_match(v.as_bytes()).unwrap_or(false) {
681                return Err(format!(
682                    "{} has an invalid {field}, it must not start with a \"-\": {v}",
683                    p.name
684                ));
685            }
686        }
687    }
688    let dotdot = crate::version::regex(&DOTDOT, r"(?:^|[\\/])\.\.(?:[\\/]|$)", false);
689    if let Some(bins) = p.raw.get("bin") {
690        let list: Vec<String> = match bins {
691            Value::String(s) => vec![s.clone()],
692            Value::Array(a) => a
693                .iter()
694                .filter_map(|v| v.as_str().map(str::to_owned))
695                .collect(),
696            Value::Object(o) => o
697                .values()
698                .filter_map(|v| v.as_str().map(str::to_owned))
699                .collect(),
700            _ => Vec::new(),
701        };
702        for bin in list {
703            if dotdot.is_match(bin.as_bytes()).unwrap_or(false) {
704                return Err(format!(
705                    "{} has an invalid bin {bin}, it must not contain \"..\" path segments",
706                    p.name
707                ));
708            }
709        }
710    }
711    Ok(())
712}
713
714/// `ValidatingArrayLoader::hasPackageNamingError($name, $isLink)`. With
715/// `is_link`, the uppercase rule names the lowercase form to use; without it,
716/// it suggests the camelCase split (`root::manifest_error` for the root
717/// package's own name, `validate_package` for a package of a repository).
718pub fn package_naming_error(name: &str, is_link: bool) -> Option<String> {
719    static NAME: OnceLock<pcre2::bytes::Regex> = OnceLock::new();
720    if crate::platform::is_platform_package(name) {
721        return None;
722    }
723    let re = crate::version::regex(
724        &NAME,
725        r"^[a-z0-9](?:[_.-]?[a-z0-9]++)*+/[a-z0-9](?:(?:[_.]|-{1,2})?[a-z0-9]++)*+\z",
726        true,
727    );
728    if !re.is_match(name.as_bytes()).unwrap_or(false) {
729        return Some(format!(
730            "{name} is invalid, it should have a vendor name, a forward slash, and a package name. The vendor and package name can be words separated by -, . or _. The complete name should match \"^[a-z0-9]([_.-]?[a-z0-9]+)*/[a-z0-9](([_.]?|-{{0,2}})[a-z0-9]+)*$\"."
731        ));
732    }
733    const RESERVED: &[&str] = &[
734        "nul", "con", "prn", "aux", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
735        "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
736    ];
737    let lower = name.to_lowercase();
738    let mut bits = lower.splitn(2, '/');
739    let vendor = bits.next().unwrap_or("");
740    let package = bits.next().unwrap_or("");
741    if RESERVED.contains(&vendor) || RESERVED.contains(&package) {
742        return Some(format!(
743            "{name} is reserved, package and vendor names can not match any of: {}.",
744            RESERVED.join(", ")
745        ));
746    }
747    if name.ends_with(".json") {
748        return Some(format!(
749            "{name} is invalid, package names can not end in .json, consider renaming it or perhaps using a -json suffix instead."
750        ));
751    }
752    if name.bytes().any(|b| b.is_ascii_uppercase()) {
753        // The shape pattern above is ASCII-only, so the name is ASCII here and
754        // `to_ascii_lowercase` is PHP's `strtolower`. A link says which name to
755        // use, a package name gets the camelCase split first.
756        if is_link {
757            return Some(format!(
758                "{name} is invalid, it should not contain uppercase characters. Please use {} instead.",
759                name.to_ascii_lowercase()
760            ));
761        }
762        return Some(format!(
763            "{name} is invalid, it should not contain uppercase characters. We suggest using {} instead.",
764            suggest_name(name)
765        ));
766    }
767    None
768}
769
770/// `hasPackageNamingError`'s suggestion for a package name (not a link):
771/// `Preg::replace('{(?:([a-z])([A-Z])|([A-Z])([A-Z][a-z]))}', '\1\3-\2\4', $name)`
772/// then `strtolower` — a dash before an upper that follows a lower, and before
773/// the last upper of a run that starts a word. Scanned left to right without
774/// overlapping, as `preg_replace` scans.
775fn suggest_name(name: &str) -> String {
776    let b = name.as_bytes();
777    let mut out = Vec::with_capacity(b.len() + 4);
778    let mut i = 0;
779    while i < b.len() {
780        let lower_upper =
781            i + 1 < b.len() && b[i].is_ascii_lowercase() && b[i + 1].is_ascii_uppercase();
782        let upper_upper_lower = i + 2 < b.len()
783            && b[i].is_ascii_uppercase()
784            && b[i + 1].is_ascii_uppercase()
785            && b[i + 2].is_ascii_lowercase();
786        if lower_upper {
787            out.extend_from_slice(&[b[i], b'-', b[i + 1]]);
788            i += 2;
789        } else if upper_upper_lower {
790            out.extend_from_slice(&[b[i], b'-', b[i + 1], b[i + 2]]);
791            i += 3;
792        } else {
793            out.push(b[i]);
794            i += 1;
795        }
796    }
797    String::from_utf8_lossy(&out).to_ascii_lowercase()
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803
804    #[test]
805    fn dates_are_reformatted_like_datetime() {
806        assert_eq!(
807            release_date(&Value::String("2020-11-13T09:40:50+00:00".into())).as_deref(),
808            Some("2020-11-13T09:40:50+00:00")
809        );
810        assert_eq!(
811            release_date(&Value::String("2020-11-13 09:40:50".into())).as_deref(),
812            Some("2020-11-13T09:40:50+00:00")
813        );
814        assert_eq!(
815            release_date(&Value::String("2020-11-13T09:40:50Z".into())).as_deref(),
816            Some("2020-11-13T09:40:50+00:00")
817        );
818        assert_eq!(
819            release_date(&Value::String("2020-11-13T09:40:50+0200".into())).as_deref(),
820            Some("2020-11-13T09:40:50+02:00")
821        );
822        assert_eq!(
823            release_date(&Value::String("2020-11-13".into())).as_deref(),
824            Some("2020-11-13T00:00:00+00:00")
825        );
826        assert_eq!(
827            release_date(&Value::String("1605260450".into())).as_deref(),
828            Some("2020-11-13T09:40:50+00:00")
829        );
830        assert_eq!(
831            release_date(&Value::String("2020-11-13 09:40:50 UTC".into())).as_deref(),
832            Some("2020-11-13T09:40:50+00:00")
833        );
834        assert_eq!(
835            release_date(&Value::String("2020-11-13 09:40:50 +0100".into())).as_deref(),
836            Some("2020-11-13T09:40:50+01:00")
837        );
838        assert_eq!(normalize_target_dir("../foo/./bar/"), "foo/bar/");
839        assert_eq!(normalize_target_dir("/Foo"), "Foo");
840        assert_eq!(release_date(&Value::String("yesterday".into())), None);
841        assert_eq!(release_date(&Value::String(String::new())), None);
842    }
843
844    #[test]
845    fn validates_packages_like_composer() {
846        use crate::package::{Origin, Package, SourceRef};
847        let mut p = Package::new("acme/lib", "1.0.0.0", "1.0.0", Origin::Repository(0));
848        assert!(validate_package(&p).is_ok());
849        p.dist = Some(SourceRef {
850            kind: "zip".into(),
851            url: " -evil".into(),
852            reference: None,
853        });
854        assert!(validate_package(&p).unwrap_err().contains("dist.url"));
855        p.dist = None;
856        p.raw = serde_json::json!({"bin": ["../x"]});
857        assert!(validate_package(&p).unwrap_err().contains("bin"));
858        assert!(package_naming_error("Acme/Lib", false).is_some());
859        assert!(package_naming_error("acme/lib.json", false).is_some());
860        assert!(package_naming_error("con/lib", false).is_some());
861        assert!(package_naming_error("acme/lib--x", false).is_none());
862        assert!(package_naming_error("php", false).is_none());
863    }
864
865    #[test]
866    fn php_string_order() {
867        assert_eq!(php_compare_strings("10", "9"), Ordering::Greater);
868        assert_eq!(php_compare_strings("a", "b"), Ordering::Less);
869        assert_eq!(php_compare_strings("Zend", "apc"), Ordering::Less);
870    }
871}