1use crate::{LockedPackage, LockfileGraph, bun, npm, pnpm, yarn};
2use std::collections::BTreeMap;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum LockfileKind {
8 Aube,
12 Pnpm,
15 Npm,
16 Yarn,
19 YarnBerry,
25 NpmShrinkwrap,
26 Bun,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct ParseOptions {
33 pub strict_store_integrity: bool,
34}
35
36impl Default for ParseOptions {
37 fn default() -> Self {
38 Self {
39 strict_store_integrity: true,
40 }
41 }
42}
43
44impl LockfileKind {
45 pub fn filename(self) -> &'static str {
46 match self {
47 LockfileKind::Aube => aube_util::embedder().lockfile_basename,
48 LockfileKind::Pnpm => "pnpm-lock.yaml",
49 LockfileKind::Npm => "package-lock.json",
50 LockfileKind::Yarn | LockfileKind::YarnBerry => "yarn.lock",
51 LockfileKind::NpmShrinkwrap => "npm-shrinkwrap.json",
52 LockfileKind::Bun => "bun.lock",
53 }
54 }
55}
56
57pub(crate) fn atomic_write_lockfile(path: &Path, body: &[u8]) -> Result<(), Error> {
64 aube_util::fs_atomic::atomic_write(path, body).map_err(|e| Error::Io(path.to_path_buf(), e))
65}
66
67pub fn write_lockfile(
71 project_dir: &Path,
72 graph: &LockfileGraph,
73 manifest: &aube_manifest::PackageJson,
74) -> Result<(), Error> {
75 write_lockfile_as(project_dir, graph, manifest, LockfileKind::Aube)?;
76 Ok(())
77}
78
79pub fn build_canonical_map(graph: &LockfileGraph) -> BTreeMap<String, &LockedPackage> {
85 let mut canonical: BTreeMap<String, &LockedPackage> = BTreeMap::new();
86 for pkg in graph.packages.values() {
87 canonical.entry(pkg.spec_key()).or_insert(pkg);
88 }
89 canonical
90}
91
92pub fn write_lockfile_preserving_existing(
98 project_dir: &Path,
99 graph: &LockfileGraph,
100 manifest: &aube_manifest::PackageJson,
101) -> Result<PathBuf, Error> {
102 let kind = detect_existing_lockfile_kind(project_dir).unwrap_or(LockfileKind::Aube);
103 write_lockfile_as(project_dir, graph, manifest, kind)
104}
105
106pub fn write_lockfile_as(
119 project_dir: &Path,
120 graph: &LockfileGraph,
121 manifest: &aube_manifest::PackageJson,
122 kind: LockfileKind,
123) -> Result<PathBuf, Error> {
124 let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Lockfile, "write")
125 .with_meta_fn(|| {
126 format!(
127 r#"{{"kind":{},"packages":{}}}"#,
128 aube_util::diag::jstr(&format!("{:?}", kind)),
129 graph.packages.len()
130 )
131 });
132 let filename = match kind {
133 LockfileKind::Aube => aube_lock_filename(project_dir),
134 LockfileKind::Pnpm => pnpm_lock_filename(project_dir),
135 other => other.filename().to_string(),
136 };
137 let path = project_dir.join(&filename);
138 match kind {
139 LockfileKind::Aube | LockfileKind::Pnpm => pnpm::write(&path, graph, manifest)?,
140 LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::write(&path, graph, manifest)?,
141 LockfileKind::Yarn => yarn::write_classic(&path, graph, manifest)?,
142 LockfileKind::YarnBerry => yarn::write_berry(&path, graph, manifest)?,
143 LockfileKind::Bun => bun::write(&path, graph, manifest)?,
144 }
145 Ok(path)
146}
147
148pub fn detect_existing_lockfile_kind(project_dir: &Path) -> Option<LockfileKind> {
157 for (path, kind) in lockfile_candidates(project_dir, true) {
158 if path.exists() {
159 return Some(refine_yarn_kind(&path, kind));
160 }
161 }
162 None
163}
164
165pub fn active_lockfile_has_conflict_markers(project_dir: &Path) -> bool {
172 for (path, _) in lockfile_candidates(project_dir, true) {
173 if !path.exists() {
174 continue;
175 }
176 return read_lockfile(&path)
177 .map(|content| has_conflict_markers(&content))
178 .unwrap_or(false);
179 }
180 false
181}
182
183fn has_conflict_markers(content: &str) -> bool {
184 content.lines().any(|line| {
185 line.starts_with("<<<<<<< ")
186 || line.trim_end_matches('\r') == "======="
187 || line.starts_with(">>>>>>> ")
188 })
189}
190
191pub fn aube_lock_filename(project_dir: &Path) -> String {
207 use std::sync::{Mutex, OnceLock};
208 static CACHE: OnceLock<Mutex<std::collections::HashMap<PathBuf, String>>> = OnceLock::new();
209 let cache = CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
210 if let Ok(map) = cache.lock()
211 && let Some(hit) = map.get(project_dir)
212 {
213 return hit.clone();
214 }
215 let basename = aube_util::embedder().lockfile_basename;
216 let (stem, ext) = basename.rsplit_once('.').unwrap_or((basename, "yaml"));
219 let resolved = if !git_branch_lockfile_enabled(project_dir) {
220 basename.to_string()
221 } else {
222 match current_git_branch(project_dir) {
223 Some(branch) => format!("{stem}.{}.{ext}", branch.replace('/', "!")),
224 None => basename.to_string(),
225 }
226 };
227 if let Ok(mut map) = cache.lock() {
228 map.insert(project_dir.to_path_buf(), resolved.clone());
229 }
230 resolved
231}
232
233pub fn pnpm_lock_filename(project_dir: &Path) -> String {
239 let aube_name = aube_lock_filename(project_dir);
240 let basename = aube_util::embedder().lockfile_basename;
243 let stem = basename.rsplit_once('.').map_or(basename, |(s, _)| s);
244 aube_name
245 .strip_prefix(&format!("{stem}."))
246 .map(|rest| format!("pnpm-lock.{rest}"))
247 .unwrap_or_else(|| "pnpm-lock.yaml".to_string())
248}
249
250fn git_branch_lockfile_enabled(project_dir: &Path) -> bool {
251 let Ok(raw) = aube_manifest::workspace::load_raw(project_dir) else {
259 return false;
260 };
261 let npmrc: Vec<(String, String)> = Vec::new();
262 let ctx = aube_settings::ResolveCtx::files_only(&npmrc, &raw);
263 aube_settings::resolved::git_branch_lockfile(&ctx)
264}
265
266pub(crate) fn current_git_branch(project_dir: &Path) -> Option<String> {
267 let out = std::process::Command::new("git")
268 .args(["-C"])
269 .arg(project_dir)
270 .args(["branch", "--show-current"])
271 .output()
272 .ok()?;
273 if !out.status.success() {
274 return None;
275 }
276 let branch = String::from_utf8(out.stdout).ok()?.trim().to_string();
277 if branch.is_empty() {
278 None
279 } else {
280 Some(branch)
281 }
282}
283
284pub fn parse_lockfile(
293 project_dir: &Path,
294 manifest: &aube_manifest::PackageJson,
295) -> Result<LockfileGraph, Error> {
296 let (graph, _kind) = parse_lockfile_with_kind(project_dir, manifest)?;
297 Ok(graph)
298}
299
300pub fn parse_lockfile_with_kind(
302 project_dir: &Path,
303 manifest: &aube_manifest::PackageJson,
304) -> Result<(LockfileGraph, LockfileKind), Error> {
305 parse_lockfile_with_kind_and_options(project_dir, manifest, ParseOptions::default())
306}
307
308pub fn parse_lockfile_with_kind_and_options(
311 project_dir: &Path,
312 manifest: &aube_manifest::PackageJson,
313 options: ParseOptions,
314) -> Result<(LockfileGraph, LockfileKind), Error> {
315 reject_bun_binary(project_dir)?;
316 for (path, kind) in lockfile_candidates(project_dir, true) {
317 if !path.exists() {
318 continue;
319 }
320 let kind = refine_yarn_kind(&path, kind);
321 let graph = parse_one(&path, kind, manifest, options)?;
322 return Ok((graph, kind));
323 }
324 Err(Error::NotFound(project_dir.to_path_buf()))
325}
326
327pub fn parse_for_import(
334 project_dir: &Path,
335 manifest: &aube_manifest::PackageJson,
336) -> Result<(LockfileGraph, LockfileKind), Error> {
337 reject_bun_binary(project_dir)?;
338 for (path, kind) in lockfile_candidates(project_dir, false) {
339 if !path.exists() {
340 continue;
341 }
342 let kind = refine_yarn_kind(&path, kind);
343 let graph = parse_one(&path, kind, manifest, ParseOptions::default())?;
344 return Ok((graph, kind));
345 }
346 Err(Error::NotFound(project_dir.to_path_buf()))
347}
348
349fn reject_bun_binary(project_dir: &Path) -> Result<(), Error> {
352 let lockb = project_dir.join("bun.lockb");
353 let text = project_dir.join("bun.lock");
354 if lockb.exists() && !text.exists() {
355 return Err(Error::parse(
356 &lockb,
357 "bun.lockb (binary format) is not supported — run `bun install --save-text-lockfile` to generate a bun.lock text file first, or upgrade to bun 1.2+ where text is the default",
358 ));
359 }
360 Ok(())
361}
362
363fn lockfile_candidates(project_dir: &Path, include_aube: bool) -> Vec<(PathBuf, LockfileKind)> {
364 let basename = aube_util::embedder().lockfile_basename;
365 let stem = basename.rsplit_once('.').map_or(basename, |(s, _)| s);
366
367 let mut aube_entries: Vec<(PathBuf, LockfileKind)> = Vec::new();
371 if include_aube {
372 let branch_name = aube_lock_filename(project_dir);
373 if branch_name != basename {
374 aube_entries.push((project_dir.join(&branch_name), LockfileKind::Aube));
375 }
376 aube_entries.push((project_dir.join(basename), LockfileKind::Aube));
377 }
378
379 let mut foreign: Vec<(PathBuf, LockfileKind)> = Vec::new();
384 let pnpm_branch = {
385 let mut s = aube_lock_filename(project_dir);
386 if let Some(rest) = s.strip_prefix(&format!("{stem}.")) {
387 s = format!("pnpm-lock.{rest}");
388 }
389 s
390 };
391 if pnpm_branch != "pnpm-lock.yaml" {
392 foreign.push((project_dir.join(&pnpm_branch), LockfileKind::Pnpm));
393 }
394 foreign.push((project_dir.join("pnpm-lock.yaml"), LockfileKind::Pnpm));
395 foreign.push((project_dir.join("bun.lock"), LockfileKind::Bun));
396 foreign.push((project_dir.join("yarn.lock"), LockfileKind::Yarn));
397 foreign.push((
398 project_dir.join("npm-shrinkwrap.json"),
399 LockfileKind::NpmShrinkwrap,
400 ));
401 foreign.push((project_dir.join("package-lock.json"), LockfileKind::Npm));
402
403 let mut out = Vec::with_capacity(aube_entries.len() + foreign.len());
409 if aube_util::embedder().canonical_lockfile_always_wins {
410 out.append(&mut aube_entries);
411 out.append(&mut foreign);
412 } else {
413 out.append(&mut foreign);
414 out.append(&mut aube_entries);
415 }
416 out
417}
418
419fn parse_one(
420 path: &Path,
421 kind: LockfileKind,
422 manifest: &aube_manifest::PackageJson,
423 options: ParseOptions,
424) -> Result<LockfileGraph, Error> {
425 let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Lockfile, "parse_one")
426 .with_meta_fn(|| {
427 let display = path
430 .file_name()
431 .map(|n| n.to_string_lossy().into_owned())
432 .unwrap_or_default();
433 format!(
434 r#"{{"kind":{},"path":{}}}"#,
435 aube_util::diag::jstr(&format!("{:?}", kind)),
436 aube_util::diag::jstr(&display)
437 )
438 });
439 let graph = match kind {
440 LockfileKind::Aube | LockfileKind::Pnpm => pnpm::parse_with_options(path, options),
445 LockfileKind::Yarn | LockfileKind::YarnBerry => yarn::parse(path, manifest),
451 LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::parse(path),
452 LockfileKind::Bun => bun::parse(path),
453 }?;
454 validate_resolution_shapes(path, &graph)?;
455 Ok(graph)
456}
457
458fn validate_resolution_shapes(path: &Path, graph: &LockfileGraph) -> Result<(), Error> {
459 validate_dependency_aliases(path, graph)?;
460 for (dep_path, pkg) in &graph.packages {
461 if pkg.local_source.is_some() && dep_path_has_registry_version(dep_path, &pkg.name) {
462 return Err(Error::ResolutionShapeMismatch(
463 path.to_path_buf(),
464 dep_path.clone(),
465 pkg.local_source
466 .as_ref()
467 .map(|source| source.kind_str())
468 .unwrap_or("unknown"),
469 ));
470 }
471 }
472 Ok(())
473}
474
475fn validate_dependency_aliases(path: &Path, graph: &LockfileGraph) -> Result<(), Error> {
476 for (importer_path, deps) in &graph.importers {
477 for dep in deps {
478 if !is_safe_package_alias(&dep.name) {
479 return Err(Error::parse(
480 path,
481 format!(
482 "importer {importer_path} has unsafe dependency alias `{}`",
483 dep.name
484 ),
485 ));
486 }
487 }
488 }
489 for (dep_path, pkg) in &graph.packages {
490 if !is_safe_package_alias(&pkg.name) {
491 return Err(Error::parse(
492 path,
493 format!("package {dep_path} has unsafe package name `{}`", pkg.name),
494 ));
495 }
496 for alias in pkg
497 .dependencies
498 .keys()
499 .chain(pkg.optional_dependencies.keys())
500 .chain(pkg.peer_dependencies.keys())
501 .chain(pkg.peer_dependencies_meta.keys())
502 .chain(pkg.declared_dependencies.keys())
503 {
504 if !is_safe_package_alias(alias) {
505 return Err(Error::parse(
506 path,
507 format!("package {dep_path} has unsafe dependency alias `{alias}`"),
508 ));
509 }
510 }
511 }
512 Ok(())
513}
514
515fn is_safe_package_alias(name: &str) -> bool {
516 if name.is_empty()
517 || name.contains('\0')
518 || name.contains('\\')
519 || name.starts_with('/')
520 || matches!(name, ".bin" | ".pnpm" | "node_modules")
521 {
522 return false;
523 }
524 let parts: Vec<&str> = name.split('/').collect();
525 match parts.as_slice() {
526 [bare] => is_safe_package_alias_component(bare),
527 [scope, bare] => {
528 scope.starts_with('@')
529 && scope.len() > 1
530 && is_safe_package_alias_component(scope)
531 && is_safe_package_alias_component(bare)
532 }
533 _ => false,
534 }
535}
536
537fn is_safe_package_alias_component(component: &str) -> bool {
538 if component.is_empty() || matches!(component, "." | "..") {
539 return false;
540 }
541 if component.len() >= 2 && component.as_bytes()[1] == b':' {
542 return false;
543 }
544 !std::path::Path::new(component).components().any(|c| {
545 matches!(
546 c,
547 std::path::Component::ParentDir
548 | std::path::Component::RootDir
549 | std::path::Component::Prefix(_)
550 )
551 })
552}
553
554fn dep_path_has_registry_version(dep_path: &str, name: &str) -> bool {
555 let Some(tail) = dep_path
556 .strip_prefix('/')
557 .unwrap_or(dep_path)
558 .strip_prefix(name)
559 .and_then(|rest| rest.strip_prefix('@'))
560 else {
561 return false;
562 };
563 let version = tail.split('(').next().unwrap_or(tail);
564 node_semver::Version::parse(version).is_ok()
565}
566
567#[cfg(test)]
568mod tests {
569 use super::{dep_path_has_registry_version, validate_dependency_aliases};
570 use crate::{
571 DepType, DirectDep, GitSource, LocalSource, LockedPackage, PeerDepMeta, RemoteTarballSource,
572 };
573 use proptest::prelude::*;
574 use std::collections::BTreeMap;
575 use std::path::{Path, PathBuf};
576
577 fn package_name() -> impl Strategy<Value = String> {
578 prop_oneof![
579 "[a-z][a-z0-9-]{0,20}".prop_map(|name| name),
580 ("[a-z][a-z0-9-]{0,10}", "[a-z][a-z0-9-]{0,20}")
581 .prop_map(|(scope, name)| format!("@{scope}/{name}")),
582 ]
583 }
584
585 fn semver() -> impl Strategy<Value = String> {
586 (0u16..1000, 0u16..1000, 0u16..1000)
587 .prop_map(|(major, minor, patch)| format!("{major}.{minor}.{patch}"))
588 }
589
590 fn path_source() -> impl Strategy<Value = LocalSource> {
591 ("[a-z][a-z0-9_-]{0,12}", prop_oneof![0u8..5, 5u8..10]).prop_map(|(path, kind)| {
592 let path = PathBuf::from(format!("./vendor/{path}"));
593 match kind {
594 0 => LocalSource::Directory(path),
595 1 => LocalSource::Tarball(path.with_extension("tgz")),
596 2 => LocalSource::Link(path),
597 3 => LocalSource::Portal(path),
598 _ => LocalSource::Exec(path),
599 }
600 })
601 }
602
603 fn local_source() -> impl Strategy<Value = LocalSource> {
604 prop_oneof![
605 path_source(),
606 "[a-z][a-z0-9-]{0,20}".prop_map(|repo| LocalSource::Git(GitSource {
607 url: format!("https://github.com/acme/{repo}.git"),
608 committish: None,
609 resolved: "0123456789abcdef0123456789abcdef01234567".to_string(),
610 integrity: None,
611 subpath: None,
612 })),
613 "[a-z][a-z0-9-]{0,20}".prop_map(|tarball| LocalSource::RemoteTarball(
614 RemoteTarballSource {
615 url: format!("https://registry.example/{tarball}.tgz"),
616 integrity: String::new(),
617 git_hosted: false,
618 },
619 )),
620 ]
621 }
622
623 #[test]
624 fn rejects_unsafe_importer_dependency_aliases() {
625 for alias in [
626 "../../../escape",
627 ".bin",
628 ".pnpm",
629 "node_modules",
630 "@scope/pkg/extra",
631 "\\evil",
632 "foo\0bar",
633 "/etc/passwd",
634 "C:pkg",
635 ] {
636 let mut graph = crate::LockfileGraph::default();
637 graph.importers.insert(
638 ".".into(),
639 vec![DirectDep {
640 name: alias.into(),
641 dep_path: "ok@1.0.0".into(),
642 dep_type: DepType::Production,
643 specifier: Some("1.0.0".into()),
644 }],
645 );
646
647 let err = validate_dependency_aliases(Path::new("pnpm-lock.yaml"), &graph)
648 .expect_err("unsafe alias must be rejected");
649 assert!(
650 err.to_string().contains("unsafe dependency alias"),
651 "unexpected error: {err}"
652 );
653 }
654 }
655
656 #[test]
657 fn rejects_unsafe_package_dependency_aliases() {
658 for package in [
659 LockedPackage {
660 name: "parent".into(),
661 version: "1.0.0".into(),
662 dep_path: "parent@1.0.0".into(),
663 dependencies: BTreeMap::from([("../escape".into(), "1.0.0".into())]),
664 ..LockedPackage::default()
665 },
666 LockedPackage {
667 name: "parent".into(),
668 version: "1.0.0".into(),
669 dep_path: "parent@1.0.0".into(),
670 declared_dependencies: BTreeMap::from([("../escape".into(), "^1.0.0".into())]),
671 ..LockedPackage::default()
672 },
673 LockedPackage {
674 name: "parent".into(),
675 version: "1.0.0".into(),
676 dep_path: "parent@1.0.0".into(),
677 peer_dependencies_meta: BTreeMap::from([(
678 "../escape".into(),
679 PeerDepMeta { optional: true },
680 )]),
681 ..LockedPackage::default()
682 },
683 ] {
684 let mut graph = crate::LockfileGraph::default();
685 graph.packages.insert("parent@1.0.0".into(), package);
686
687 let err = validate_dependency_aliases(Path::new("pnpm-lock.yaml"), &graph)
688 .expect_err("unsafe alias must be rejected");
689 assert!(
690 err.to_string()
691 .contains("package parent@1.0.0 has unsafe dependency alias `../escape`"),
692 "unexpected error: {err}"
693 );
694 }
695 }
696
697 #[test]
698 fn accepts_valid_scoped_and_unscoped_dependency_aliases() {
699 let mut graph = crate::LockfileGraph::default();
700 graph.importers.insert(
701 ".".into(),
702 vec![
703 DirectDep {
704 name: "left-pad".into(),
705 dep_path: "left-pad@1.3.0".into(),
706 dep_type: DepType::Production,
707 specifier: Some("1.3.0".into()),
708 },
709 DirectDep {
710 name: "@scope/pkg".into(),
711 dep_path: "@scope/pkg@1.0.0".into(),
712 dep_type: DepType::Dev,
713 specifier: Some("1.0.0".into()),
714 },
715 ],
716 );
717 graph.packages.insert(
718 "parent@1.0.0".into(),
719 LockedPackage {
720 name: "parent".into(),
721 version: "1.0.0".into(),
722 dep_path: "parent@1.0.0".into(),
723 dependencies: BTreeMap::from([
724 ("left-pad".into(), "1.3.0".into()),
725 ("@scope/pkg".into(), "1.0.0".into()),
726 ]),
727 ..LockedPackage::default()
728 },
729 );
730
731 validate_dependency_aliases(Path::new("pnpm-lock.yaml"), &graph)
732 .expect("valid aliases should pass");
733 }
734
735 proptest! {
736 #[test]
737 fn dep_path_registry_version_accepts_name_at_semver(name in package_name(), version in semver()) {
738 let dep_path = format!("{name}@{version}");
739 prop_assert!(dep_path_has_registry_version(&dep_path, &name));
740 }
741
742 #[test]
743 fn dep_path_registry_version_rejects_local_source_dep_paths(
744 name in package_name(),
745 source in local_source(),
746 ) {
747 let dep_path = source.dep_path(&name);
748 prop_assert!(!dep_path_has_registry_version(&dep_path, &name));
749 }
750 }
751}
752
753fn refine_yarn_kind(path: &Path, kind: LockfileKind) -> LockfileKind {
762 if kind == LockfileKind::Yarn && yarn::is_berry_path(path) {
763 LockfileKind::YarnBerry
764 } else {
765 kind
766 }
767}
768
769#[derive(Debug, thiserror::Error, miette::Diagnostic)]
770pub enum Error {
771 #[error("no lockfile found in {0}")]
772 #[diagnostic(code(ERR_AUBE_NO_LOCKFILE))]
773 NotFound(std::path::PathBuf),
774 #[error("unsupported lockfile format: {0}")]
775 #[diagnostic(code(ERR_AUBE_LOCKFILE_UNSUPPORTED_FORMAT))]
776 UnsupportedFormat(String),
777 #[error(
778 "lockfile {path} contains named-registry package `{dep_path}` from `{registry_name}:`, which aube does not support yet"
779 )]
780 #[diagnostic(
781 code(ERR_AUBE_UNSUPPORTED_NAMED_REGISTRY),
782 help(
783 "aube cannot install this lockfile yet; use pnpm 11.20 or newer instead for this project"
784 )
785 )]
786 UnsupportedNamedRegistry {
787 path: std::path::PathBuf,
788 dep_path: String,
789 registry_name: String,
790 },
791 #[error("failed to read lockfile {0}: {1}")]
792 Io(std::path::PathBuf, std::io::Error),
793 #[error("failed to parse lockfile {0}: {1}")]
798 #[diagnostic(code(ERR_AUBE_LOCKFILE_PARSE))]
799 Parse(std::path::PathBuf, String),
800 #[error("lockfile {0} has registry-style dependency path `{1}` backed by {2} resolution")]
801 #[diagnostic(
802 code(ERR_AUBE_RESOLUTION_SHAPE_MISMATCH),
803 help(
804 "run `aube install --no-frozen-lockfile` from a trusted manifest to regenerate the lockfile"
805 )
806 )]
807 ResolutionShapeMismatch(std::path::PathBuf, String, &'static str),
808 #[error(transparent)]
814 #[diagnostic(transparent)]
815 ParseDiag(Box<aube_manifest::ParseError>),
816}
817
818pub fn read_lockfile(path: &std::path::Path) -> Result<String, Error> {
820 std::fs::read_to_string(path).map_err(|e| Error::Io(path.to_path_buf(), e))
821}
822
823pub fn parse_json<T: serde::de::DeserializeOwned>(
826 path: &std::path::Path,
827 content: String,
828) -> Result<T, Error> {
829 match sonic_rs::from_slice(content.as_bytes()) {
832 Ok(v) => Ok(v),
833 Err(_) => match serde_json::from_str(&content) {
834 Ok(v) => Ok(v),
835 Err(e) => Err(Error::parse_json_err(path, content, &e)),
836 },
837 }
838}
839
840impl Error {
841 pub fn parse(path: &std::path::Path, msg: impl Into<String>) -> Self {
842 Error::Parse(path.to_path_buf(), msg.into())
843 }
844
845 pub fn parse_json_err(
846 path: &std::path::Path,
847 content: String,
848 err: &serde_json::Error,
849 ) -> Self {
850 Error::ParseDiag(Box::new(aube_manifest::ParseError::from_json_err(
851 path, content, err,
852 )))
853 }
854
855 pub fn parse_yaml_err(
856 path: &std::path::Path,
857 content: String,
858 err: &yaml_serde::Error,
859 ) -> Self {
860 Error::ParseDiag(Box::new(aube_manifest::ParseError::from_yaml_err(
861 path, content, err,
862 )))
863 }
864}
865
866#[cfg(test)]
867mod parse_diag_tests {
868 use super::*;
869 use crate::{LocalSource, LockedPackage};
870 use std::path::Path;
871
872 #[test]
876 fn parse_json_attaches_span_for_bad_input() {
877 let path = Path::new("package-lock.json");
878 let content = r#"{"name":"x","#.to_string();
879 let Err(Error::ParseDiag(pe)) = parse_json::<serde_json::Value>(path, content.clone())
880 else {
881 panic!("parse_json must produce ParseDiag on malformed input");
882 };
883 let offset: usize = pe.span.offset();
884 let len: usize = pe.span.len();
885 assert!(offset + len <= content.len());
886 assert_eq!(pe.path, path);
887 }
888
889 #[test]
890 fn validate_resolution_shapes_rejects_local_source_with_registry_dep_path() {
891 let mut graph = LockfileGraph::default();
892 graph.packages.insert(
893 "left-pad@1.3.0".to_string(),
894 LockedPackage {
895 name: "left-pad".to_string(),
896 version: "1.3.0".to_string(),
897 dep_path: "left-pad@1.3.0".to_string(),
898 local_source: Some(LocalSource::Directory("vendor/left-pad".into())),
899 ..Default::default()
900 },
901 );
902
903 let err = validate_resolution_shapes(Path::new("pnpm-lock.yaml"), &graph).unwrap_err();
904 assert!(matches!(
905 err,
906 Error::ResolutionShapeMismatch(_, dep_path, "file")
907 if dep_path == "left-pad@1.3.0"
908 ));
909 }
910
911 #[test]
912 fn validate_resolution_shapes_rejects_peer_suffixed_registry_dep_path() {
913 let mut graph = LockfileGraph::default();
914 graph.packages.insert(
915 "plugin@1.0.0(react@19.0.0)".to_string(),
916 LockedPackage {
917 name: "plugin".to_string(),
918 version: "1.0.0".to_string(),
919 dep_path: "plugin@1.0.0(react@19.0.0)".to_string(),
920 local_source: Some(LocalSource::RemoteTarball(crate::RemoteTarballSource {
921 url: "https://example.com/plugin.tgz".to_string(),
922 integrity: "sha512-test".to_string(),
923 git_hosted: false,
924 })),
925 ..Default::default()
926 },
927 );
928
929 let err = validate_resolution_shapes(Path::new("pnpm-lock.yaml"), &graph).unwrap_err();
930 assert!(matches!(
931 err,
932 Error::ResolutionShapeMismatch(_, dep_path, "url")
933 if dep_path == "plugin@1.0.0(react@19.0.0)"
934 ));
935 }
936
937 #[test]
938 fn validate_resolution_shapes_allows_local_source_dep_path() {
939 let source = LocalSource::Directory("vendor/left-pad".into());
940 let dep_path = source.dep_path("left-pad");
941 let mut graph = LockfileGraph::default();
942 graph.packages.insert(
943 dep_path.clone(),
944 LockedPackage {
945 name: "left-pad".to_string(),
946 version: "1.3.0".to_string(),
947 dep_path,
948 local_source: Some(source),
949 ..Default::default()
950 },
951 );
952
953 validate_resolution_shapes(Path::new("pnpm-lock.yaml"), &graph).unwrap();
954 }
955
956 #[test]
963 fn parse_yaml_err_attaches_span_for_bad_input() {
964 let path = Path::new("yarn.lock");
965 let content = "packages:\n\t- pkg\n".to_string();
966 let yaml_err: yaml_serde::Error = yaml_serde::from_str::<yaml_serde::Value>(&content)
967 .expect_err("tab-indented YAML must fail");
968 let Error::ParseDiag(pe) = Error::parse_yaml_err(path, content.clone(), &yaml_err) else {
969 panic!("parse_yaml_err must produce ParseDiag");
970 };
971 let offset: usize = pe.span.offset();
972 let len: usize = pe.span.len();
973 assert!(offset + len <= content.len());
974 assert_eq!(pe.path, path);
975 }
976}
977
978#[cfg(test)]
979mod filename_tests {
980 use super::*;
981
982 #[test]
983 fn defaults_to_plain_lockfile_when_setting_absent() {
984 let dir = tempfile::tempdir().unwrap();
985 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
986 assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.yaml");
987 }
988
989 #[test]
990 fn defaults_to_plain_lockfile_when_setting_explicit_false() {
991 let dir = tempfile::tempdir().unwrap();
992 std::fs::write(
993 dir.path().join("pnpm-workspace.yaml"),
994 "gitBranchLockfile: false\n",
995 )
996 .unwrap();
997 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
998 }
999
1000 #[test]
1001 fn uses_branch_filename_when_enabled_inside_git_repo() {
1002 let dir = tempfile::tempdir().unwrap();
1003 std::fs::write(
1004 dir.path().join("pnpm-workspace.yaml"),
1005 "gitBranchLockfile: true\n",
1006 )
1007 .unwrap();
1008 let run = |args: &[&str]| {
1011 std::process::Command::new("git")
1012 .args(["-C"])
1013 .arg(dir.path())
1014 .args(args)
1015 .output()
1016 .unwrap()
1017 };
1018 if run(&["init", "-q"]).status.success() {
1019 run(&["checkout", "-q", "-b", "feature/x"]);
1020 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.feature!x.yaml");
1021 assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.feature!x.yaml");
1022 }
1023 }
1024}