Skip to main content

aube_lockfile/
io.rs

1use crate::{LockedPackage, LockfileGraph, bun, npm, pnpm, yarn};
2use std::collections::BTreeMap;
3use std::path::{Path, PathBuf};
4
5/// Which source lockfile format was parsed.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum LockfileKind {
8    /// `aube-lock.yaml` — aube's default lockfile when no existing
9    /// lockfile is present. Same on-disk format as pnpm v9 for now
10    /// (we piggyback on pnpm::read/write).
11    Aube,
12    /// `pnpm-lock.yaml` — pnpm v9 format. If this is the existing
13    /// project lockfile, aube reads and writes it in place.
14    Pnpm,
15    Npm,
16    /// `yarn.lock` v1 (classic yarn). Line-based text format with
17    /// 2-space indented fields.
18    Yarn,
19    /// `yarn.lock` v2+ (yarn berry). YAML format with `__metadata:`
20    /// header, `resolution:` / `checksum:` fields, and
21    /// `languageName` / `linkType`. Same filename as `Yarn`; detection
22    /// peeks at the content for the `__metadata:` marker to pick
23    /// between the two.
24    YarnBerry,
25    NpmShrinkwrap,
26    Bun,
27}
28
29/// Options that affect lockfile parsing without changing the graph
30/// shape. Defaults preserve the historic strict parser behavior.
31#[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
57/// Atomic lockfile write. Tempfile in the same dir, fsync, rename
58/// over the target. Every format writer goes through this so a
59/// crash or Ctrl+C mid-write cannot leave a truncated lockfile on
60/// disk. Rename is atomic on POSIX, on Windows MoveFileEx gives
61/// the same guarantee post Win10. Caller passes the serialized
62/// bytes already formatted, this just handles the IO layer.
63pub(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
67/// Write a lockfile to the given project directory using aube's default
68/// filename (`aube-lock.yaml`, or `aube-lock.<branch>.yaml` when branch
69/// lockfiles are enabled).
70pub 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
79/// Collapse peer-context variants from `graph` into a single map keyed
80/// by `"name@version"`, pointing at the first-seen package. Several
81/// writers (npm, yarn, …) share this shape: one canonical entry per
82/// `(name, version)` pair regardless of how many peer suffixes the
83/// full graph emits.
84pub 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
92/// Write a lockfile using the existing project lockfile kind, or
93/// `aube-lock.yaml` when the project does not have one yet.
94///
95/// This is the default write path for commands that mutate the active
96/// project graph (`install`, `add`, `remove`, `update`, `dedupe`, ...).
97pub 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
106/// Write `graph` in the requested lockfile format into `project_dir`.
107///
108/// Returns the path that was actually written (useful for logging
109/// since `Aube` may resolve to a branch-specific filename). Callers
110/// that want to preserve whatever format was already on disk should
111/// pair this with [`detect_existing_lockfile_kind`].
112///
113/// All supported formats: `Aube`, `Pnpm`, `Npm`, `NpmShrinkwrap`,
114/// `Yarn`, and `Bun`. This preserves the lockfile kind that already
115/// exists in the project; callers should pass `Aube` only when no
116/// lockfile exists yet. See each writer module's doc comment for
117/// per-format lossy areas (peer contexts, `resolved` URLs, etc.).
118pub 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
148/// Return the [`LockfileKind`] of the lockfile already on disk in
149/// `project_dir`, if any. Follows the same precedence as
150/// [`parse_lockfile_with_kind`] (aube > pnpm > bun > yarn >
151/// npm-shrinkwrap > npm). Used by install to preserve a project's
152/// existing lockfile format when rewriting after a re-resolve — a
153/// user with only `pnpm-lock.yaml`, `package-lock.json`, or another
154/// supported lockfile gets that file written back, not a surprise
155/// `aube-lock.yaml` alongside it.
156pub fn detect_existing_lockfile_kind(project_dir: &Path) -> Option<LockfileKind> {
157    for (path, kind) in lockfile_candidates(project_dir, /*include_aube=*/ true) {
158        if path.exists() {
159            return Some(refine_yarn_kind(&path, kind));
160        }
161    }
162    None
163}
164
165/// Return true when the active lockfile contains Git conflict markers.
166///
167/// Used by install's prefer-frozen path to distinguish a merge/rebase
168/// artifact from an arbitrary parse error: conflict markers can be
169/// repaired by regenerating from the already-resolved `package.json`,
170/// while other parse failures should stay loud.
171pub fn active_lockfile_has_conflict_markers(project_dir: &Path) -> bool {
172    for (path, _) in lockfile_candidates(project_dir, /*include_aube=*/ 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
191/// Resolve the canonical lockfile filename for `project_dir` (aube's own).
192///
193/// Returns `aube-lock.<branch>.yaml` when `gitBranchLockfile: true` is
194/// set in `pnpm-workspace.yaml` (or `aube-workspace.yaml`) and the
195/// project is inside a git checkout with a current branch. Forward
196/// slashes in the branch name are encoded as `!`, matching pnpm. Falls
197/// back to plain `aube-lock.yaml` in every other case.
198///
199/// Memoized per `project_dir` for the lifetime of the process: a
200/// single install resolves this 3–5 times (lockfile_candidates,
201/// write_lockfile, debug log, state read/write), and
202/// `check_needs_install` runs on every `aube run`/`aube exec` via
203/// `ensure_installed`. Without caching, every command would pay for a
204/// YAML parse + a `git branch --show-current` subprocess just to
205/// recompute a value that can't change mid-process.
206pub 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    // basename is "<stem>.<ext>" (e.g. "aube-lock.yaml"); branch lockfiles
217    // splice the branch in as "<stem>.<branch>.<ext>".
218    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
233/// Resolve the pnpm lockfile filename for `project_dir`.
234///
235/// Mirrors [`aube_lock_filename`] for branch lockfiles, but keeps the
236/// pnpm filename prefix so projects with an existing `pnpm-lock.yaml`
237/// keep writing to pnpm's file.
238pub fn pnpm_lock_filename(project_dir: &Path) -> String {
239    let aube_name = aube_lock_filename(project_dir);
240    // `aube_lock_filename` always returns "<stem>.<rest>", so strip_prefix
241    // always succeeds. The fallback is purely defensive.
242    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    // Goes through the build-time-generated typed accessor in
252    // `aube_settings::resolved` so the alias list is driven off
253    // `settings.toml` — no hand-maintained typed field. This path
254    // reads only `pnpm-workspace.yaml`; `.npmrc` values are out of
255    // scope here because aube-lockfile doesn't want a dependency on
256    // aube-registry just to load npmrc (and the historical behavior
257    // never read `.npmrc` either).
258    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
284/// Detect and parse the lockfile in the given project directory.
285///
286/// Priority: `aube-lock.yaml` → `pnpm-lock.yaml` → `bun.lock` →
287/// `yarn.lock` → `npm-shrinkwrap.json` → `package-lock.json`.
288/// (Shrinkwrap takes priority over package-lock.json when both exist, matching npm's behavior.)
289///
290/// `manifest` is needed to classify direct vs transitive deps when
291/// reading yarn.lock (which has no notion of that distinction).
292pub 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
300/// Like [`parse_lockfile`] but also returns which format was read.
301pub 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
308/// Like [`parse_lockfile_with_kind`] but lets callers opt into parser
309/// behavior driven by resolved install settings.
310pub 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, /*include_aube=*/ 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
327/// Variant of [`parse_lockfile_with_kind`] used by `aube import`.
328///
329/// Skips `aube-lock.yaml` — if the project already has one, there's
330/// nothing to import. `pnpm-lock.yaml` *is* included because the whole
331/// point of `aube import` is to convert a foreign lockfile (including
332/// pnpm's) into `aube-lock.yaml`.
333pub 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, /*include_aube=*/ 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
349/// If only `bun.lockb` is present (without a text `bun.lock`), surface an
350/// actionable error instead of silently falling through to another format.
351fn 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    // The canonical (Aube) candidates: the branch-specific lockfile (if
368    // `gitBranchLockfile` is on and we resolve a branch) then the plain
369    // canonical lockfile, so a freshly-enabled branch still picks up the base.
370    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    // The foreign candidates, in their fixed precedence order. Preserve pnpm
380    // lockfiles in place; the branch-specific `pnpm-lock.<branch>.yaml`
381    // mirrors the aube branch naming so a project already on pnpm branch
382    // lockfiles keeps writing through that file.
383    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    // `Embedder::canonical_lockfile_always_wins` (aube default true) controls
404    // whether the canonical lockfile outranks any foreign one present: when
405    // true the Aube candidates lead, when false a foreign lockfile that also
406    // exists wins instead (the Aube candidates still trail so a lone canonical
407    // lockfile remains usable). Embedder-fixed, not a per-project setting.
408    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            // Emit only the file name (e.g. `aube-lock.yaml`) so traces
428            // do not leak absolute project paths.
429            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        // `aube-lock.yaml` uses the same on-disk format as pnpm v9 for
441        // now — same parser, same writer — so we piggyback on the pnpm
442        // module. Keeping the variant distinct lets detection/import
443        // treat the two differently even though the bytes are the same.
444        LockfileKind::Aube | LockfileKind::Pnpm => pnpm::parse_with_options(path, options),
445        // yarn.rs::parse peeks the file for `__metadata:` and
446        // dispatches between classic (v1) and berry (v2+) internally,
447        // so we can hand both kinds to the same entry point. The
448        // caller keeps the kind label it resolved from
449        // `refine_yarn_kind` for downstream write-back.
450        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
753/// Replace `LockfileKind::Yarn` with `LockfileKind::YarnBerry` when
754/// the yarn.lock at `path` is actually a yarn 2+ lockfile. Other
755/// kinds pass through unchanged.
756///
757/// `lockfile_candidates` only knows filenames, not content, so the
758/// yarn entry is always tagged `Yarn`. Callers that need the precise
759/// variant (install write-back, import conversions, drift logging)
760/// funnel through this helper after confirming the candidate exists.
761fn 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    /// Structural/serialization lockfile errors that have no source
794    /// location — shape checks (`must be a mapping`), version guards
795    /// (`lockfileVersion N unsupported`), and `yaml_serde::to_string`
796    /// failures during write.
797    #[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    /// Deserialization failure with a byte offset into the source
809    /// content, so miette's `fancy` handler can draw a pointer at the
810    /// offending byte of the lockfile. Reuses `aube_manifest`'s
811    /// `ParseError` — identical shape, identical rendering — via the
812    /// same `ParseDiag` pattern `aube-workspace` uses.
813    #[error(transparent)]
814    #[diagnostic(transparent)]
815    ParseDiag(Box<aube_manifest::ParseError>),
816}
817
818/// Read a lockfile from disk, mapping I/O errors to `Error::Io`.
819pub 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
823/// Parse a JSON lockfile document, attaching a miette source span on
824/// failure so the fancy handler can point at the offending byte.
825pub fn parse_json<T: serde::de::DeserializeOwned>(
826    path: &std::path::Path,
827    content: String,
828) -> Result<T, Error> {
829    // sonic-rs takes an immutable &[u8], so the original `content`
830    // bytes stay intact for the serde_json fallback's diagnostic.
831    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    /// Trailing `,` in an otherwise fine JSON lockfile — confirm the
873    /// helper attaches a `NamedSource` pointed at the lockfile path and
874    /// the span stays in bounds so miette can render a pointer.
875    #[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    /// Same story for YAML — yaml_serde reports a `Location` with a
957    /// byte index directly, so no line/col conversion is exercised
958    /// here. Both production sites (`pnpm.rs`, `yarn.rs`) call
959    /// `Error::parse_yaml_err` directly (one iterates multiple YAML
960    /// documents, the other has only borrowed content), so that's the
961    /// entry point this test locks down.
962    #[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        // git init + checkout a branch with a `/` so we exercise the
1009        // pnpm-style `!` encoding.
1010        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}