1use 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
12pub 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
24fn is_array(v: &Value) -> bool {
26 matches!(v, Value::Array(_) | Value::Object(_))
27}
28
29pub 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
53fn 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
65fn 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
93pub 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
112type 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 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 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
217pub 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 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 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 if let Some(bin) = raw.get("bin").filter(|v| !v.is_null()) {
327 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 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 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 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 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 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
505pub 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
560pub 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 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
644pub 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
714pub 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 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
770fn 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}