1#[derive(Debug, Clone, Default)]
29pub struct SupportedArchitectures {
30 pub os: Vec<String>,
31 pub cpu: Vec<String>,
32 pub libc: Vec<String>,
33 pub accept_all: bool,
43}
44
45impl SupportedArchitectures {
46 fn combinations(&self) -> Vec<(String, String, String)> {
50 let host = host_triple();
51 let expand = |field: &[String], host_val: &str| -> Vec<String> {
52 if field.is_empty() {
53 return vec![host_val.to_string()];
54 }
55 field
56 .iter()
57 .map(|v| {
58 if v == "current" {
59 host_val.to_string()
60 } else {
61 v.clone()
62 }
63 })
64 .collect()
65 };
66 let os = expand(&self.os, host.0);
67 let cpu = expand(&self.cpu, host.1);
68 let libc = expand(&self.libc, host.2);
69 let mut out = Vec::with_capacity(os.len() * cpu.len() * libc.len());
70 for o in &os {
71 for c in &cpu {
72 for l in &libc {
73 out.push((o.clone(), c.clone(), l.clone()));
74 }
75 }
76 }
77 out
78 }
79}
80
81pub fn host_triple() -> (&'static str, &'static str, &'static str) {
86 let os = match std::env::consts::OS {
87 "macos" => "darwin",
88 "windows" => "win32",
89 other => other,
90 };
91 let cpu = match std::env::consts::ARCH {
92 "x86_64" => "x64",
93 "x86" => "ia32",
94 "aarch64" => "arm64",
95 "powerpc64" => "ppc64",
96 other => other,
97 };
98 let libc = if std::env::consts::OS == "linux" {
106 detect_linux_libc()
107 } else {
108 ""
109 };
110 (os, cpu, libc)
111}
112
113fn detect_linux_libc() -> &'static str {
130 use std::sync::OnceLock;
131 static CACHE: OnceLock<&'static str> = OnceLock::new();
132 CACHE.get_or_init(|| {
133 if let Ok(maps) = std::fs::read_to_string("/proc/self/maps") {
134 if maps.contains("/ld-musl-") {
135 return "musl";
136 }
137 if maps.contains("/ld-linux") {
138 return "glibc";
139 }
140 }
141 let glibc_dirs = [
142 "/lib",
143 "/lib64",
144 "/lib/x86_64-linux-gnu",
145 "/lib/aarch64-linux-gnu",
146 ];
147 for dir in glibc_dirs {
148 if let Ok(entries) = std::fs::read_dir(dir) {
149 for entry in entries.flatten() {
150 let name = entry.file_name();
151 if name.to_string_lossy().starts_with("ld-linux") {
152 return "glibc";
153 }
154 }
155 }
156 }
157 if let Ok(entries) = std::fs::read_dir("/lib") {
158 for entry in entries.flatten() {
159 let name = entry.file_name();
160 if name.to_string_lossy().starts_with("ld-musl-") {
161 return "musl";
162 }
163 }
164 }
165 "glibc"
166 })
167}
168
169fn field_matches(pkg_field: &[String], host: &str) -> bool {
173 if pkg_field.is_empty() {
174 return true;
175 }
176 let mut has_positive = false;
177 let mut positive_matched = false;
178 for entry in pkg_field {
179 if let Some(neg) = entry.strip_prefix('!') {
180 if neg == host {
181 return false;
182 }
183 } else {
184 has_positive = true;
185 if entry == host {
186 positive_matched = true;
187 }
188 }
189 }
190 !has_positive || positive_matched
191}
192
193pub fn is_supported(
198 pkg_os: &[String],
199 pkg_cpu: &[String],
200 pkg_libc: &[String],
201 supported: &SupportedArchitectures,
202) -> bool {
203 if supported.accept_all {
208 return true;
209 }
210 for (os, cpu, libc) in supported.combinations() {
211 if !field_matches(pkg_os, &os) {
212 continue;
213 }
214 if !field_matches(pkg_cpu, &cpu) {
215 continue;
216 }
217 if !libc.is_empty() && !field_matches(pkg_libc, &libc) {
218 continue;
219 }
220 return true;
221 }
222 false
223}
224
225pub fn filter_graph(
236 graph: &mut aube_lockfile::LockfileGraph,
237 supported: &SupportedArchitectures,
238 ignored: &std::collections::BTreeSet<String>,
239) {
240 use crate::FxHashSet;
241 use aube_lockfile::DepType;
242
243 let is_mismatched =
244 |pkg: &aube_lockfile::LockedPackage| !is_supported(&pkg.os, &pkg.cpu, &pkg.libc, supported);
245
246 for deps in graph.importers.values_mut() {
248 deps.retain(|dep| {
249 if dep.dep_type != DepType::Optional {
250 return true;
251 }
252 if ignored.contains(&dep.name) {
253 return false;
254 }
255 !matches!(graph.packages.get(&dep.dep_path), Some(pkg) if is_mismatched(pkg))
256 });
257 }
258
259 let package_keys: FxHashSet<String> = graph.packages.keys().cloned().collect();
263 let mismatched_packages: FxHashSet<String> = graph
264 .packages
265 .iter()
266 .filter(|(_, pkg)| is_mismatched(pkg))
267 .map(|(dep_path, _)| dep_path.clone())
268 .collect();
269 for pkg in graph.packages.values_mut() {
270 let mut removed = Vec::new();
271 pkg.optional_dependencies.retain(|name, tail| {
272 let child_is_mismatched =
277 match aube_lockfile::resolve_dep_edge(name, tail, |k| package_keys.contains(k)) {
278 Some(child_key) => mismatched_packages.contains(&child_key),
279 None => false,
280 };
281 let keep = !ignored.contains(name) && !child_is_mismatched;
282 if !keep {
283 removed.push(name.clone());
284 }
285 keep
286 });
287 for name in removed {
288 pkg.dependencies.remove(&name);
289 }
290 }
291
292 let mut reachable: FxHashSet<String> = FxHashSet::default();
295 let mut stack: Vec<String> = Vec::new();
296 for deps in graph.importers.values() {
297 for dep in deps {
298 stack.push(dep.dep_path.clone());
299 }
300 }
301 while let Some(dep_path) = stack.pop() {
302 if !reachable.insert(dep_path.clone()) {
303 continue;
304 }
305 if let Some(pkg) = graph.packages.get(&dep_path) {
306 for (name, tail) in pkg
309 .dependencies
310 .iter()
311 .chain(pkg.optional_dependencies.iter())
312 {
313 if let Some(child) =
318 aube_lockfile::resolve_dep_edge(name, tail, |k| graph.packages.contains_key(k))
319 {
320 stack.push(child);
321 }
322 }
323 }
324 }
325 graph.packages.retain(|k, _| reachable.contains(k));
326}
327
328pub fn mark_optional_packages(graph: &mut aube_lockfile::LockfileGraph) {
345 use crate::FxHashSet;
346 use aube_lockfile::DepType;
347
348 let mut required: FxHashSet<String> = FxHashSet::default();
349 let mut stack: Vec<String> = Vec::new();
350 for deps in graph.importers.values() {
351 for dep in deps {
352 if dep.dep_type != DepType::Optional {
353 stack.push(dep.dep_path.clone());
354 }
355 }
356 }
357 while let Some(dep_path) = stack.pop() {
358 if !required.insert(dep_path.clone()) {
359 continue;
360 }
361 let Some(pkg) = graph.packages.get(&dep_path) else {
362 continue;
363 };
364 for (name, tail) in &pkg.dependencies {
365 if pkg.optional_dependencies.contains_key(name) {
369 continue;
370 }
371 if let Some(child) =
375 aube_lockfile::resolve_dep_edge(name, tail, |k| graph.packages.contains_key(k))
376 {
377 stack.push(child);
378 }
379 }
380 }
381 for (dep_path, pkg) in graph.packages.iter_mut() {
382 pkg.optional = !required.contains(dep_path);
383 }
384}
385
386pub fn mark_transitive_peer_dependencies(graph: &mut aube_lockfile::LockfileGraph) {
399 use crate::{FxHashMap, FxHashSet};
400 use std::collections::BTreeSet;
401
402 let mut parents: FxHashMap<String, Vec<String>> = FxHashMap::default();
405 let mut unresolved: FxHashMap<String, Vec<String>> = FxHashMap::default();
406
407 for (dep_path, pkg) in &graph.packages {
408 for (name, tail) in pkg
409 .dependencies
410 .iter()
411 .chain(pkg.optional_dependencies.iter())
412 {
413 if pkg.peer_dependencies.contains_key(name)
422 || pkg.peer_dependencies_meta.contains_key(name)
423 {
424 continue;
425 }
426 if let Some(child) =
430 aube_lockfile::resolve_dep_edge(name, tail, |k| graph.packages.contains_key(k))
431 {
432 parents.entry(child).or_default().push(dep_path.clone());
433 } else {
434 tracing::debug!(
440 parent = %dep_path,
441 dep = %name,
442 tail = %tail,
443 "transitive-peer pass: dependency edge has no graph node, skipping"
444 );
445 }
446 }
447 let own: BTreeSet<String> = pkg
455 .peer_dependencies_with_meta_defaults()
456 .into_keys()
457 .filter(|p| !pkg.dependencies.contains_key(p))
458 .collect();
459 if !own.is_empty() {
460 unresolved.insert(dep_path.clone(), own.into_iter().collect());
461 }
462 }
463
464 let mut acc: FxHashMap<String, BTreeSet<String>> = FxHashMap::default();
468 for (origin, peers) in &unresolved {
469 let mut visited: FxHashSet<String> = FxHashSet::default();
470 visited.insert(origin.clone());
471 let mut stack: Vec<String> = parents.get(origin).cloned().unwrap_or_default();
472 while let Some(node) = stack.pop() {
473 if !visited.insert(node.clone()) {
474 continue;
475 }
476 let entry = acc.entry(node.clone()).or_default();
477 entry.extend(peers.iter().cloned());
478 if let Some(ps) = parents.get(&node) {
479 stack.extend(ps.iter().cloned());
480 }
481 }
482 }
483
484 for (dep_path, pkg) in graph.packages.iter_mut() {
485 pkg.transitive_peer_dependencies = acc
486 .get(dep_path)
487 .map(|s| s.iter().cloned().collect())
488 .unwrap_or_default();
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 fn s(xs: &[&str]) -> Vec<String> {
497 xs.iter().map(|x| (*x).to_string()).collect()
498 }
499
500 #[test]
501 fn empty_fields_accept_any_host() {
502 let sup = SupportedArchitectures::default();
503 assert!(is_supported(&[], &[], &[], &sup));
504 }
505
506 #[test]
507 fn positive_match_rules() {
508 assert!(field_matches(&s(&["linux", "darwin"]), "linux"));
509 assert!(!field_matches(&s(&["linux", "darwin"]), "win32"));
510 }
511
512 #[test]
513 fn negation_rejects_match() {
514 assert!(!field_matches(&s(&["!win32"]), "win32"));
515 assert!(field_matches(&s(&["!win32"]), "linux"));
516 }
517
518 #[test]
519 fn mixed_negation_and_positive() {
520 assert!(!field_matches(&s(&["linux", "!linux"]), "linux"));
523 }
524
525 #[test]
526 fn supported_architectures_widens_with_current() {
527 let sup = SupportedArchitectures {
529 os: s(&["current", "linux"]),
530 ..Default::default()
531 };
532 assert!(is_supported(&s(&["linux"]), &[], &[], &sup));
534 }
535
536 #[test]
537 fn accept_all_accepts_every_arch_including_non_host_triples() {
538 let sup = SupportedArchitectures {
545 accept_all: true,
546 ..Default::default()
547 };
548 assert!(is_supported(&s(&["darwin"]), &s(&["x64"]), &[], &sup));
549 assert!(is_supported(&s(&["freebsd"]), &s(&["arm64"]), &[], &sup));
550 assert!(is_supported(
551 &s(&["linux"]),
552 &s(&["ppc64"]),
553 &s(&["glibc"]),
554 &sup
555 ));
556 assert!(is_supported(
557 &s(&["openharmony"]),
558 &s(&["arm64"]),
559 &[],
560 &sup
561 ));
562 assert!(is_supported(&s(&["win32"]), &s(&["ia32"]), &[], &sup));
563 let host_only = SupportedArchitectures::default();
566 let (host_os, _, _) = host_triple();
567 if host_os != "freebsd" {
568 assert!(!is_supported(
569 &s(&["freebsd"]),
570 &s(&["arm64"]),
571 &[],
572 &host_only
573 ));
574 }
575 }
576
577 #[test]
578 fn filter_graph_prunes_transitive_optional_platform_mismatches() {
579 let supported = SupportedArchitectures {
580 os: s(&["darwin"]),
581 cpu: s(&["arm64"]),
582 ..Default::default()
583 };
584 let mut graph = aube_lockfile::LockfileGraph::default();
585 graph.importers.insert(
586 ".".to_string(),
587 vec![aube_lockfile::DirectDep {
588 name: "host".to_string(),
589 dep_path: "host@1.0.0".to_string(),
590 dep_type: aube_lockfile::DepType::Production,
591 specifier: Some("1.0.0".to_string()),
592 }],
593 );
594 graph.packages.insert(
595 "host@1.0.0".to_string(),
596 aube_lockfile::LockedPackage {
597 name: "host".to_string(),
598 version: "1.0.0".to_string(),
599 dep_path: "host@1.0.0".to_string(),
600 dependencies: [
601 ("native-darwin".to_string(), "1.0.0".to_string()),
602 ("native-linux".to_string(), "1.0.0".to_string()),
603 ]
604 .into(),
605 optional_dependencies: [
606 ("native-darwin".to_string(), "1.0.0".to_string()),
607 ("native-linux".to_string(), "1.0.0".to_string()),
608 ]
609 .into(),
610 ..Default::default()
611 },
612 );
613 graph.packages.insert(
614 "native-darwin@1.0.0".to_string(),
615 aube_lockfile::LockedPackage {
616 name: "native-darwin".to_string(),
617 version: "1.0.0".to_string(),
618 dep_path: "native-darwin@1.0.0".to_string(),
619 os: s(&["darwin"]).into(),
620 cpu: s(&["arm64"]).into(),
621 ..Default::default()
622 },
623 );
624 graph.packages.insert(
625 "native-linux@1.0.0".to_string(),
626 aube_lockfile::LockedPackage {
627 name: "native-linux".to_string(),
628 version: "1.0.0".to_string(),
629 dep_path: "native-linux@1.0.0".to_string(),
630 os: s(&["linux"]).into(),
631 cpu: s(&["x64"]).into(),
632 ..Default::default()
633 },
634 );
635
636 filter_graph(&mut graph, &supported, &Default::default());
637
638 let host = graph.packages.get("host@1.0.0").unwrap();
639 assert!(host.dependencies.contains_key("native-darwin"));
640 assert!(!host.dependencies.contains_key("native-linux"));
641 assert!(graph.packages.contains_key("native-darwin@1.0.0"));
642 assert!(!graph.packages.contains_key("native-linux@1.0.0"));
643 }
644
645 #[test]
646 fn filter_graph_keeps_supported_yarn_berry_optional_children() {
647 let supported = SupportedArchitectures {
648 os: s(&["darwin"]),
649 cpu: s(&["arm64"]),
650 ..Default::default()
651 };
652 let mut graph = aube_lockfile::LockfileGraph::default();
653 graph.importers.insert(
654 ".".to_string(),
655 vec![aube_lockfile::DirectDep {
656 name: "host".to_string(),
657 dep_path: "host@1.0.0".to_string(),
658 dep_type: aube_lockfile::DepType::Production,
659 specifier: Some("1.0.0".to_string()),
660 }],
661 );
662 graph.packages.insert(
663 "host@1.0.0".to_string(),
664 aube_lockfile::LockedPackage {
665 name: "host".to_string(),
666 version: "1.0.0".to_string(),
667 dep_path: "host@1.0.0".to_string(),
668 dependencies: Default::default(),
670 optional_dependencies: [("native-darwin".to_string(), "1.0.0".to_string())].into(),
671 ..Default::default()
672 },
673 );
674 graph.packages.insert(
675 "native-darwin@1.0.0".to_string(),
676 aube_lockfile::LockedPackage {
677 name: "native-darwin".to_string(),
678 version: "1.0.0".to_string(),
679 dep_path: "native-darwin@1.0.0".to_string(),
680 os: s(&["darwin"]).into(),
681 cpu: s(&["arm64"]).into(),
682 ..Default::default()
683 },
684 );
685
686 filter_graph(&mut graph, &supported, &Default::default());
687
688 assert!(graph.packages.contains_key("native-darwin@1.0.0"));
689 }
690
691 fn dep(name: &str, dep_type: aube_lockfile::DepType) -> aube_lockfile::DirectDep {
692 aube_lockfile::DirectDep {
693 name: name.to_string(),
694 dep_path: format!("{name}@1.0.0"),
695 dep_type,
696 specifier: Some("1.0.0".to_string()),
697 }
698 }
699
700 fn pkg(name: &str, deps: &[&str], opt_deps: &[&str]) -> (String, aube_lockfile::LockedPackage) {
701 let dep_path = format!("{name}@1.0.0");
702 (
703 dep_path.clone(),
704 aube_lockfile::LockedPackage {
705 name: name.to_string(),
706 version: "1.0.0".to_string(),
707 dep_path,
708 dependencies: deps
709 .iter()
710 .map(|d| ((*d).to_string(), "1.0.0".to_string()))
711 .collect(),
712 optional_dependencies: opt_deps
713 .iter()
714 .map(|d| ((*d).to_string(), "1.0.0".to_string()))
715 .collect(),
716 ..Default::default()
717 },
718 )
719 }
720
721 #[test]
722 fn mark_optional_packages_marks_optional_only_reachable() {
723 use aube_lockfile::DepType;
724 let mut graph = aube_lockfile::LockfileGraph::default();
725 graph.importers.insert(
726 ".".to_string(),
727 vec![
728 dep("host", DepType::Production),
729 dep("also-required", DepType::Production),
730 dep("opt-root", DepType::Optional),
731 ],
732 );
733 graph.packages.extend([
738 pkg(
739 "host",
740 &["shared", "native-darwin", "native-linux", "dual"],
741 &["native-darwin", "native-linux", "dual"],
742 ),
743 pkg("also-required", &["dual"], &[]),
744 pkg("shared", &[], &[]),
745 pkg("native-darwin", &[], &[]),
746 pkg("native-linux", &[], &[]),
747 pkg("dual", &[], &[]),
748 pkg("opt-root", &[], &[]),
749 ]);
750
751 mark_optional_packages(&mut graph);
752
753 let is_opt = |k: &str| graph.packages[k].optional;
754 assert!(!is_opt("host@1.0.0"));
756 assert!(!is_opt("also-required@1.0.0"));
757 assert!(!is_opt("shared@1.0.0"));
758 assert!(!is_opt("dual@1.0.0"));
760 assert!(is_opt("native-darwin@1.0.0"));
762 assert!(is_opt("native-linux@1.0.0"));
763 assert!(is_opt("opt-root@1.0.0"));
765 }
766
767 fn pkg_with_peers(
768 name: &str,
769 deps: &[&str],
770 peers: &[&str],
771 ) -> (String, aube_lockfile::LockedPackage) {
772 let (key, mut p) = pkg(name, deps, &[]);
773 p.peer_dependencies = peers
774 .iter()
775 .map(|d| ((*d).to_string(), "*".to_string()))
776 .collect();
777 (key, p)
778 }
779
780 #[test]
781 fn transitive_peer_dependencies_bubble_unresolved_peers() {
782 let mut graph = aube_lockfile::LockfileGraph::default();
783 graph.packages.extend([
784 pkg("app", &["host", "mid"], &[]),
785 pkg_with_peers("host", &["core"], &["core"]),
788 pkg("core", &[], &[]),
789 pkg("mid", &["leaf"], &[]),
792 pkg_with_peers("leaf", &["ms"], &["supports-color"]),
793 pkg("ms", &[], &[]),
794 ]);
795
796 mark_transitive_peer_dependencies(&mut graph);
797
798 let tp = |k: &str| graph.packages[k].transitive_peer_dependencies.clone();
799 assert_eq!(tp("app@1.0.0"), vec!["supports-color".to_string()]);
801 assert_eq!(tp("mid@1.0.0"), vec!["supports-color".to_string()]);
802 assert!(tp("leaf@1.0.0").is_empty());
804 assert!(tp("ms@1.0.0").is_empty());
805 assert!(tp("host@1.0.0").is_empty());
807 assert!(tp("core@1.0.0").is_empty());
808 }
809
810 #[test]
811 fn transitive_peer_dependencies_handle_cycles_without_self() {
812 let mut graph = aube_lockfile::LockfileGraph::default();
813 graph.packages.extend([
815 pkg_with_peers("a", &["b"], &["pa"]),
816 pkg_with_peers("b", &["a"], &["pb"]),
817 ]);
818
819 mark_transitive_peer_dependencies(&mut graph);
820
821 assert_eq!(
824 graph.packages["a@1.0.0"].transitive_peer_dependencies,
825 vec!["pb".to_string()]
826 );
827 assert_eq!(
828 graph.packages["b@1.0.0"].transitive_peer_dependencies,
829 vec!["pa".to_string()]
830 );
831 }
832
833 #[test]
834 fn filter_graph_prunes_npm_lockfile_transitive_optional_platform_mismatch() {
835 let content = r#"{
836 "name": "platform-optional-root",
837 "version": "1.0.0",
838 "lockfileVersion": 3,
839 "packages": {
840 "": {
841 "name": "platform-optional-root",
842 "version": "1.0.0",
843 "dependencies": { "host": "file:host" }
844 },
845 "node_modules/host": {
846 "resolved": "host",
847 "link": true
848 },
849 "host": {
850 "name": "host",
851 "version": "1.0.0",
852 "optionalDependencies": { "native-win": "1.0.0" }
853 },
854 "node_modules/native-win": {
855 "version": "1.0.0",
856 "resolved": "https://registry.npmjs.org/native-win/-/native-win-1.0.0.tgz",
857 "integrity": "sha512-native",
858 "optional": true,
859 "os": ["win32"],
860 "cpu": ["x64"],
861 "libc": ["glibc"]
862 }
863 }
864 }"#;
865 let tmp = tempfile::NamedTempFile::new().unwrap();
866 std::fs::write(tmp.path(), content).unwrap();
867 let mut graph = aube_lockfile::npm::parse(tmp.path()).unwrap();
868
869 let host_dep_path = graph.importers["."][0].dep_path.clone();
870 assert!(
871 graph.packages.contains_key(&host_dep_path),
872 "fixture must contain the host package before filtering"
873 );
874 assert!(
875 graph.packages.contains_key("native-win@1.0.0"),
876 "fixture must contain native-win before filtering"
877 );
878 let host = &graph.packages[&host_dep_path];
879 assert!(host.dependencies.contains_key("native-win"));
880 assert!(host.optional_dependencies.contains_key("native-win"));
881
882 let supported = SupportedArchitectures {
883 os: s(&["linux"]),
884 cpu: s(&["x64"]),
885 libc: s(&["glibc"]),
886 ..Default::default()
887 };
888 filter_graph(&mut graph, &supported, &Default::default());
889
890 assert!(graph.packages.contains_key(&host_dep_path));
891 assert!(!graph.packages.contains_key("native-win@1.0.0"));
892 let host = &graph.packages[&host_dep_path];
893 assert!(!host.dependencies.contains_key("native-win"));
894 assert!(!host.optional_dependencies.contains_key("native-win"));
895 }
896
897 #[cfg(not(target_os = "linux"))]
898 #[test]
899 fn libc_ignored_off_linux() {
900 let sup = SupportedArchitectures::default();
903 assert!(is_supported(&[], &[], &s(&["musl"]), &sup));
904 }
905
906 #[cfg(target_os = "linux")]
907 #[test]
908 fn linux_glibc_host_rejects_musl_only_package() {
909 if cfg!(target_env = "musl") {
914 return;
915 }
916 let sup = SupportedArchitectures::default();
917 assert!(!is_supported(&[], &[], &s(&["musl"]), &sup));
918 }
919}