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
29impl LockfileKind {
30    pub fn filename(self) -> &'static str {
31        match self {
32            LockfileKind::Aube => aube_util::embedder().lockfile_basename,
33            LockfileKind::Pnpm => "pnpm-lock.yaml",
34            LockfileKind::Npm => "package-lock.json",
35            LockfileKind::Yarn | LockfileKind::YarnBerry => "yarn.lock",
36            LockfileKind::NpmShrinkwrap => "npm-shrinkwrap.json",
37            LockfileKind::Bun => "bun.lock",
38        }
39    }
40}
41
42/// Atomic lockfile write. Tempfile in the same dir, fsync, rename
43/// over the target. Every format writer goes through this so a
44/// crash or Ctrl+C mid-write cannot leave a truncated lockfile on
45/// disk. Rename is atomic on POSIX, on Windows MoveFileEx gives
46/// the same guarantee post Win10. Caller passes the serialized
47/// bytes already formatted, this just handles the IO layer.
48pub(crate) fn atomic_write_lockfile(path: &Path, body: &[u8]) -> Result<(), Error> {
49    aube_util::fs_atomic::atomic_write(path, body).map_err(|e| Error::Io(path.to_path_buf(), e))
50}
51
52/// Write a lockfile to the given project directory using aube's default
53/// filename (`aube-lock.yaml`, or `aube-lock.<branch>.yaml` when branch
54/// lockfiles are enabled).
55pub fn write_lockfile(
56    project_dir: &Path,
57    graph: &LockfileGraph,
58    manifest: &aube_manifest::PackageJson,
59) -> Result<(), Error> {
60    write_lockfile_as(project_dir, graph, manifest, LockfileKind::Aube)?;
61    Ok(())
62}
63
64/// Collapse peer-context variants from `graph` into a single map keyed
65/// by `"name@version"`, pointing at the first-seen package. Several
66/// writers (npm, yarn, …) share this shape: one canonical entry per
67/// `(name, version)` pair regardless of how many peer suffixes the
68/// full graph emits.
69pub fn build_canonical_map(graph: &LockfileGraph) -> BTreeMap<String, &LockedPackage> {
70    let mut canonical: BTreeMap<String, &LockedPackage> = BTreeMap::new();
71    for pkg in graph.packages.values() {
72        canonical.entry(pkg.spec_key()).or_insert(pkg);
73    }
74    canonical
75}
76
77/// Write a lockfile using the existing project lockfile kind, or
78/// `aube-lock.yaml` when the project does not have one yet.
79///
80/// This is the default write path for commands that mutate the active
81/// project graph (`install`, `add`, `remove`, `update`, `dedupe`, ...).
82pub fn write_lockfile_preserving_existing(
83    project_dir: &Path,
84    graph: &LockfileGraph,
85    manifest: &aube_manifest::PackageJson,
86) -> Result<PathBuf, Error> {
87    let kind = detect_existing_lockfile_kind(project_dir).unwrap_or(LockfileKind::Aube);
88    write_lockfile_as(project_dir, graph, manifest, kind)
89}
90
91/// Write `graph` in the requested lockfile format into `project_dir`.
92///
93/// Returns the path that was actually written (useful for logging
94/// since `Aube` may resolve to a branch-specific filename). Callers
95/// that want to preserve whatever format was already on disk should
96/// pair this with [`detect_existing_lockfile_kind`].
97///
98/// All supported formats: `Aube`, `Pnpm`, `Npm`, `NpmShrinkwrap`,
99/// `Yarn`, and `Bun`. This preserves the lockfile kind that already
100/// exists in the project; callers should pass `Aube` only when no
101/// lockfile exists yet. See each writer module's doc comment for
102/// per-format lossy areas (peer contexts, `resolved` URLs, etc.).
103pub fn write_lockfile_as(
104    project_dir: &Path,
105    graph: &LockfileGraph,
106    manifest: &aube_manifest::PackageJson,
107    kind: LockfileKind,
108) -> Result<PathBuf, Error> {
109    let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Lockfile, "write")
110        .with_meta_fn(|| {
111            format!(
112                r#"{{"kind":{},"packages":{}}}"#,
113                aube_util::diag::jstr(&format!("{:?}", kind)),
114                graph.packages.len()
115            )
116        });
117    let filename = match kind {
118        LockfileKind::Aube => aube_lock_filename(project_dir),
119        LockfileKind::Pnpm => pnpm_lock_filename(project_dir),
120        other => other.filename().to_string(),
121    };
122    let path = project_dir.join(&filename);
123    match kind {
124        LockfileKind::Aube | LockfileKind::Pnpm => pnpm::write(&path, graph, manifest)?,
125        LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::write(&path, graph, manifest)?,
126        LockfileKind::Yarn => yarn::write_classic(&path, graph, manifest)?,
127        LockfileKind::YarnBerry => yarn::write_berry(&path, graph, manifest)?,
128        LockfileKind::Bun => bun::write(&path, graph, manifest)?,
129    }
130    Ok(path)
131}
132
133/// Return the [`LockfileKind`] of the lockfile already on disk in
134/// `project_dir`, if any. Follows the same precedence as
135/// [`parse_lockfile_with_kind`] (aube > pnpm > bun > yarn >
136/// npm-shrinkwrap > npm). Used by install to preserve a project's
137/// existing lockfile format when rewriting after a re-resolve — a
138/// user with only `pnpm-lock.yaml`, `package-lock.json`, or another
139/// supported lockfile gets that file written back, not a surprise
140/// `aube-lock.yaml` alongside it.
141pub fn detect_existing_lockfile_kind(project_dir: &Path) -> Option<LockfileKind> {
142    for (path, kind) in lockfile_candidates(project_dir, /*include_aube=*/ true) {
143        if path.exists() {
144            return Some(refine_yarn_kind(&path, kind));
145        }
146    }
147    None
148}
149
150/// Return true when the active lockfile contains Git conflict markers.
151///
152/// Used by install's prefer-frozen path to distinguish a merge/rebase
153/// artifact from an arbitrary parse error: conflict markers can be
154/// repaired by regenerating from the already-resolved `package.json`,
155/// while other parse failures should stay loud.
156pub fn active_lockfile_has_conflict_markers(project_dir: &Path) -> bool {
157    for (path, _) in lockfile_candidates(project_dir, /*include_aube=*/ true) {
158        if !path.exists() {
159            continue;
160        }
161        return read_lockfile(&path)
162            .map(|content| has_conflict_markers(&content))
163            .unwrap_or(false);
164    }
165    false
166}
167
168fn has_conflict_markers(content: &str) -> bool {
169    content.lines().any(|line| {
170        line.starts_with("<<<<<<< ")
171            || line.trim_end_matches('\r') == "======="
172            || line.starts_with(">>>>>>> ")
173    })
174}
175
176/// Resolve the canonical lockfile filename for `project_dir` (aube's own).
177///
178/// Returns `aube-lock.<branch>.yaml` when `gitBranchLockfile: true` is
179/// set in `pnpm-workspace.yaml` (or `aube-workspace.yaml`) and the
180/// project is inside a git checkout with a current branch. Forward
181/// slashes in the branch name are encoded as `!`, matching pnpm. Falls
182/// back to plain `aube-lock.yaml` in every other case.
183///
184/// Memoized per `project_dir` for the lifetime of the process: a
185/// single install resolves this 3–5 times (lockfile_candidates,
186/// write_lockfile, debug log, state read/write), and
187/// `check_needs_install` runs on every `aube run`/`aube exec` via
188/// `ensure_installed`. Without caching, every command would pay for a
189/// YAML parse + a `git branch --show-current` subprocess just to
190/// recompute a value that can't change mid-process.
191pub fn aube_lock_filename(project_dir: &Path) -> String {
192    use std::sync::{Mutex, OnceLock};
193    static CACHE: OnceLock<Mutex<std::collections::HashMap<PathBuf, String>>> = OnceLock::new();
194    let cache = CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
195    if let Ok(map) = cache.lock()
196        && let Some(hit) = map.get(project_dir)
197    {
198        return hit.clone();
199    }
200    let basename = aube_util::embedder().lockfile_basename;
201    // basename is "<stem>.<ext>" (e.g. "aube-lock.yaml"); branch lockfiles
202    // splice the branch in as "<stem>.<branch>.<ext>".
203    let (stem, ext) = basename.rsplit_once('.').unwrap_or((basename, "yaml"));
204    let resolved = if !git_branch_lockfile_enabled(project_dir) {
205        basename.to_string()
206    } else {
207        match current_git_branch(project_dir) {
208            Some(branch) => format!("{stem}.{}.{ext}", branch.replace('/', "!")),
209            None => basename.to_string(),
210        }
211    };
212    if let Ok(mut map) = cache.lock() {
213        map.insert(project_dir.to_path_buf(), resolved.clone());
214    }
215    resolved
216}
217
218/// Resolve the pnpm lockfile filename for `project_dir`.
219///
220/// Mirrors [`aube_lock_filename`] for branch lockfiles, but keeps the
221/// pnpm filename prefix so projects with an existing `pnpm-lock.yaml`
222/// keep writing to pnpm's file.
223pub fn pnpm_lock_filename(project_dir: &Path) -> String {
224    let aube_name = aube_lock_filename(project_dir);
225    // `aube_lock_filename` always returns "<stem>.<rest>", so strip_prefix
226    // always succeeds. The fallback is purely defensive.
227    let basename = aube_util::embedder().lockfile_basename;
228    let stem = basename.rsplit_once('.').map_or(basename, |(s, _)| s);
229    aube_name
230        .strip_prefix(&format!("{stem}."))
231        .map(|rest| format!("pnpm-lock.{rest}"))
232        .unwrap_or_else(|| "pnpm-lock.yaml".to_string())
233}
234
235fn git_branch_lockfile_enabled(project_dir: &Path) -> bool {
236    // Goes through the build-time-generated typed accessor in
237    // `aube_settings::resolved` so the alias list is driven off
238    // `settings.toml` — no hand-maintained typed field. This path
239    // reads only `pnpm-workspace.yaml`; `.npmrc` values are out of
240    // scope here because aube-lockfile doesn't want a dependency on
241    // aube-registry just to load npmrc (and the historical behavior
242    // never read `.npmrc` either).
243    let Ok(raw) = aube_manifest::workspace::load_raw(project_dir) else {
244        return false;
245    };
246    let npmrc: Vec<(String, String)> = Vec::new();
247    let ctx = aube_settings::ResolveCtx::files_only(&npmrc, &raw);
248    aube_settings::resolved::git_branch_lockfile(&ctx)
249}
250
251pub(crate) fn current_git_branch(project_dir: &Path) -> Option<String> {
252    let out = std::process::Command::new("git")
253        .args(["-C"])
254        .arg(project_dir)
255        .args(["branch", "--show-current"])
256        .output()
257        .ok()?;
258    if !out.status.success() {
259        return None;
260    }
261    let branch = String::from_utf8(out.stdout).ok()?.trim().to_string();
262    if branch.is_empty() {
263        None
264    } else {
265        Some(branch)
266    }
267}
268
269/// Detect and parse the lockfile in the given project directory.
270///
271/// Priority: `aube-lock.yaml` → `pnpm-lock.yaml` → `bun.lock` →
272/// `yarn.lock` → `npm-shrinkwrap.json` → `package-lock.json`.
273/// (Shrinkwrap takes priority over package-lock.json when both exist, matching npm's behavior.)
274///
275/// `manifest` is needed to classify direct vs transitive deps when
276/// reading yarn.lock (which has no notion of that distinction).
277pub fn parse_lockfile(
278    project_dir: &Path,
279    manifest: &aube_manifest::PackageJson,
280) -> Result<LockfileGraph, Error> {
281    let (graph, _kind) = parse_lockfile_with_kind(project_dir, manifest)?;
282    Ok(graph)
283}
284
285/// Like [`parse_lockfile`] but also returns which format was read.
286pub fn parse_lockfile_with_kind(
287    project_dir: &Path,
288    manifest: &aube_manifest::PackageJson,
289) -> Result<(LockfileGraph, LockfileKind), Error> {
290    reject_bun_binary(project_dir)?;
291    for (path, kind) in lockfile_candidates(project_dir, /*include_aube=*/ true) {
292        if !path.exists() {
293            continue;
294        }
295        let kind = refine_yarn_kind(&path, kind);
296        let graph = parse_one(&path, kind, manifest)?;
297        return Ok((graph, kind));
298    }
299    Err(Error::NotFound(project_dir.to_path_buf()))
300}
301
302/// Variant of [`parse_lockfile_with_kind`] used by `aube import`.
303///
304/// Skips `aube-lock.yaml` — if the project already has one, there's
305/// nothing to import. `pnpm-lock.yaml` *is* included because the whole
306/// point of `aube import` is to convert a foreign lockfile (including
307/// pnpm's) into `aube-lock.yaml`.
308pub fn parse_for_import(
309    project_dir: &Path,
310    manifest: &aube_manifest::PackageJson,
311) -> Result<(LockfileGraph, LockfileKind), Error> {
312    reject_bun_binary(project_dir)?;
313    for (path, kind) in lockfile_candidates(project_dir, /*include_aube=*/ false) {
314        if !path.exists() {
315            continue;
316        }
317        let kind = refine_yarn_kind(&path, kind);
318        let graph = parse_one(&path, kind, manifest)?;
319        return Ok((graph, kind));
320    }
321    Err(Error::NotFound(project_dir.to_path_buf()))
322}
323
324/// If only `bun.lockb` is present (without a text `bun.lock`), surface an
325/// actionable error instead of silently falling through to another format.
326fn reject_bun_binary(project_dir: &Path) -> Result<(), Error> {
327    let lockb = project_dir.join("bun.lockb");
328    let text = project_dir.join("bun.lock");
329    if lockb.exists() && !text.exists() {
330        return Err(Error::parse(
331            &lockb,
332            "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",
333        ));
334    }
335    Ok(())
336}
337
338fn lockfile_candidates(project_dir: &Path, include_aube: bool) -> Vec<(PathBuf, LockfileKind)> {
339    let basename = aube_util::embedder().lockfile_basename;
340    let stem = basename.rsplit_once('.').map_or(basename, |(s, _)| s);
341
342    // The canonical (Aube) candidates: the branch-specific lockfile (if
343    // `gitBranchLockfile` is on and we resolve a branch) then the plain
344    // canonical lockfile, so a freshly-enabled branch still picks up the base.
345    let mut aube_entries: Vec<(PathBuf, LockfileKind)> = Vec::new();
346    if include_aube {
347        let branch_name = aube_lock_filename(project_dir);
348        if branch_name != basename {
349            aube_entries.push((project_dir.join(&branch_name), LockfileKind::Aube));
350        }
351        aube_entries.push((project_dir.join(basename), LockfileKind::Aube));
352    }
353
354    // The foreign candidates, in their fixed precedence order. Preserve pnpm
355    // lockfiles in place; the branch-specific `pnpm-lock.<branch>.yaml`
356    // mirrors the aube branch naming so a project already on pnpm branch
357    // lockfiles keeps writing through that file.
358    let mut foreign: Vec<(PathBuf, LockfileKind)> = Vec::new();
359    let pnpm_branch = {
360        let mut s = aube_lock_filename(project_dir);
361        if let Some(rest) = s.strip_prefix(&format!("{stem}.")) {
362            s = format!("pnpm-lock.{rest}");
363        }
364        s
365    };
366    if pnpm_branch != "pnpm-lock.yaml" {
367        foreign.push((project_dir.join(&pnpm_branch), LockfileKind::Pnpm));
368    }
369    foreign.push((project_dir.join("pnpm-lock.yaml"), LockfileKind::Pnpm));
370    foreign.push((project_dir.join("bun.lock"), LockfileKind::Bun));
371    foreign.push((project_dir.join("yarn.lock"), LockfileKind::Yarn));
372    foreign.push((
373        project_dir.join("npm-shrinkwrap.json"),
374        LockfileKind::NpmShrinkwrap,
375    ));
376    foreign.push((project_dir.join("package-lock.json"), LockfileKind::Npm));
377
378    // `Embedder::canonical_lockfile_always_wins` (aube default true) controls
379    // whether the canonical lockfile outranks any foreign one present: when
380    // true the Aube candidates lead, when false a foreign lockfile that also
381    // exists wins instead (the Aube candidates still trail so a lone canonical
382    // lockfile remains usable). Embedder-fixed, not a per-project setting.
383    let mut out = Vec::with_capacity(aube_entries.len() + foreign.len());
384    if aube_util::embedder().canonical_lockfile_always_wins {
385        out.append(&mut aube_entries);
386        out.append(&mut foreign);
387    } else {
388        out.append(&mut foreign);
389        out.append(&mut aube_entries);
390    }
391    out
392}
393
394fn parse_one(
395    path: &Path,
396    kind: LockfileKind,
397    manifest: &aube_manifest::PackageJson,
398) -> Result<LockfileGraph, Error> {
399    let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Lockfile, "parse_one")
400        .with_meta_fn(|| {
401            // Emit only the file name (e.g. `aube-lock.yaml`) so traces
402            // do not leak absolute project paths.
403            let display = path
404                .file_name()
405                .map(|n| n.to_string_lossy().into_owned())
406                .unwrap_or_default();
407            format!(
408                r#"{{"kind":{},"path":{}}}"#,
409                aube_util::diag::jstr(&format!("{:?}", kind)),
410                aube_util::diag::jstr(&display)
411            )
412        });
413    let graph = match kind {
414        // `aube-lock.yaml` uses the same on-disk format as pnpm v9 for
415        // now — same parser, same writer — so we piggyback on the pnpm
416        // module. Keeping the variant distinct lets detection/import
417        // treat the two differently even though the bytes are the same.
418        LockfileKind::Aube | LockfileKind::Pnpm => pnpm::parse(path),
419        // yarn.rs::parse peeks the file for `__metadata:` and
420        // dispatches between classic (v1) and berry (v2+) internally,
421        // so we can hand both kinds to the same entry point. The
422        // caller keeps the kind label it resolved from
423        // `refine_yarn_kind` for downstream write-back.
424        LockfileKind::Yarn | LockfileKind::YarnBerry => yarn::parse(path, manifest),
425        LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::parse(path),
426        LockfileKind::Bun => bun::parse(path),
427    }?;
428    validate_resolution_shapes(path, &graph)?;
429    Ok(graph)
430}
431
432fn validate_resolution_shapes(path: &Path, graph: &LockfileGraph) -> Result<(), Error> {
433    validate_dependency_aliases(path, graph)?;
434    for (dep_path, pkg) in &graph.packages {
435        if pkg.local_source.is_some() && dep_path_has_registry_version(dep_path, &pkg.name) {
436            return Err(Error::ResolutionShapeMismatch(
437                path.to_path_buf(),
438                dep_path.clone(),
439                pkg.local_source
440                    .as_ref()
441                    .map(|source| source.kind_str())
442                    .unwrap_or("unknown"),
443            ));
444        }
445    }
446    Ok(())
447}
448
449fn validate_dependency_aliases(path: &Path, graph: &LockfileGraph) -> Result<(), Error> {
450    for (importer_path, deps) in &graph.importers {
451        for dep in deps {
452            if !is_safe_package_alias(&dep.name) {
453                return Err(Error::parse(
454                    path,
455                    format!(
456                        "importer {importer_path} has unsafe dependency alias `{}`",
457                        dep.name
458                    ),
459                ));
460            }
461        }
462    }
463    for (dep_path, pkg) in &graph.packages {
464        if !is_safe_package_alias(&pkg.name) {
465            return Err(Error::parse(
466                path,
467                format!("package {dep_path} has unsafe package name `{}`", pkg.name),
468            ));
469        }
470        for alias in pkg
471            .dependencies
472            .keys()
473            .chain(pkg.optional_dependencies.keys())
474            .chain(pkg.peer_dependencies.keys())
475            .chain(pkg.peer_dependencies_meta.keys())
476            .chain(pkg.declared_dependencies.keys())
477        {
478            if !is_safe_package_alias(alias) {
479                return Err(Error::parse(
480                    path,
481                    format!("package {dep_path} has unsafe dependency alias `{alias}`"),
482                ));
483            }
484        }
485    }
486    Ok(())
487}
488
489fn is_safe_package_alias(name: &str) -> bool {
490    if name.is_empty()
491        || name.contains('\0')
492        || name.contains('\\')
493        || name.starts_with('/')
494        || matches!(name, ".bin" | ".pnpm" | "node_modules")
495    {
496        return false;
497    }
498    let parts: Vec<&str> = name.split('/').collect();
499    match parts.as_slice() {
500        [bare] => is_safe_package_alias_component(bare),
501        [scope, bare] => {
502            scope.starts_with('@')
503                && scope.len() > 1
504                && is_safe_package_alias_component(scope)
505                && is_safe_package_alias_component(bare)
506        }
507        _ => false,
508    }
509}
510
511fn is_safe_package_alias_component(component: &str) -> bool {
512    if component.is_empty() || matches!(component, "." | "..") {
513        return false;
514    }
515    if component.len() >= 2 && component.as_bytes()[1] == b':' {
516        return false;
517    }
518    !std::path::Path::new(component).components().any(|c| {
519        matches!(
520            c,
521            std::path::Component::ParentDir
522                | std::path::Component::RootDir
523                | std::path::Component::Prefix(_)
524        )
525    })
526}
527
528fn dep_path_has_registry_version(dep_path: &str, name: &str) -> bool {
529    let Some(tail) = dep_path
530        .strip_prefix('/')
531        .unwrap_or(dep_path)
532        .strip_prefix(name)
533        .and_then(|rest| rest.strip_prefix('@'))
534    else {
535        return false;
536    };
537    let version = tail.split('(').next().unwrap_or(tail);
538    node_semver::Version::parse(version).is_ok()
539}
540
541#[cfg(test)]
542mod tests {
543    use super::{dep_path_has_registry_version, validate_dependency_aliases};
544    use crate::{
545        DepType, DirectDep, GitSource, LocalSource, LockedPackage, PeerDepMeta, RemoteTarballSource,
546    };
547    use proptest::prelude::*;
548    use std::collections::BTreeMap;
549    use std::path::{Path, PathBuf};
550
551    fn package_name() -> impl Strategy<Value = String> {
552        prop_oneof![
553            "[a-z][a-z0-9-]{0,20}".prop_map(|name| name),
554            ("[a-z][a-z0-9-]{0,10}", "[a-z][a-z0-9-]{0,20}")
555                .prop_map(|(scope, name)| format!("@{scope}/{name}")),
556        ]
557    }
558
559    fn semver() -> impl Strategy<Value = String> {
560        (0u16..1000, 0u16..1000, 0u16..1000)
561            .prop_map(|(major, minor, patch)| format!("{major}.{minor}.{patch}"))
562    }
563
564    fn path_source() -> impl Strategy<Value = LocalSource> {
565        ("[a-z][a-z0-9_-]{0,12}", prop_oneof![0u8..5, 5u8..10]).prop_map(|(path, kind)| {
566            let path = PathBuf::from(format!("./vendor/{path}"));
567            match kind {
568                0 => LocalSource::Directory(path),
569                1 => LocalSource::Tarball(path.with_extension("tgz")),
570                2 => LocalSource::Link(path),
571                3 => LocalSource::Portal(path),
572                _ => LocalSource::Exec(path),
573            }
574        })
575    }
576
577    fn local_source() -> impl Strategy<Value = LocalSource> {
578        prop_oneof![
579            path_source(),
580            "[a-z][a-z0-9-]{0,20}".prop_map(|repo| LocalSource::Git(GitSource {
581                url: format!("https://github.com/acme/{repo}.git"),
582                committish: None,
583                resolved: "0123456789abcdef0123456789abcdef01234567".to_string(),
584                integrity: None,
585                subpath: None,
586            })),
587            "[a-z][a-z0-9-]{0,20}".prop_map(|tarball| LocalSource::RemoteTarball(
588                RemoteTarballSource {
589                    url: format!("https://registry.example/{tarball}.tgz"),
590                    integrity: String::new(),
591                    git_hosted: false,
592                },
593            )),
594        ]
595    }
596
597    #[test]
598    fn rejects_unsafe_importer_dependency_aliases() {
599        for alias in [
600            "../../../escape",
601            ".bin",
602            ".pnpm",
603            "node_modules",
604            "@scope/pkg/extra",
605            "\\evil",
606            "foo\0bar",
607            "/etc/passwd",
608            "C:pkg",
609        ] {
610            let mut graph = crate::LockfileGraph::default();
611            graph.importers.insert(
612                ".".into(),
613                vec![DirectDep {
614                    name: alias.into(),
615                    dep_path: "ok@1.0.0".into(),
616                    dep_type: DepType::Production,
617                    specifier: Some("1.0.0".into()),
618                }],
619            );
620
621            let err = validate_dependency_aliases(Path::new("pnpm-lock.yaml"), &graph)
622                .expect_err("unsafe alias must be rejected");
623            assert!(
624                err.to_string().contains("unsafe dependency alias"),
625                "unexpected error: {err}"
626            );
627        }
628    }
629
630    #[test]
631    fn rejects_unsafe_package_dependency_aliases() {
632        for package in [
633            LockedPackage {
634                name: "parent".into(),
635                version: "1.0.0".into(),
636                dep_path: "parent@1.0.0".into(),
637                dependencies: BTreeMap::from([("../escape".into(), "1.0.0".into())]),
638                ..LockedPackage::default()
639            },
640            LockedPackage {
641                name: "parent".into(),
642                version: "1.0.0".into(),
643                dep_path: "parent@1.0.0".into(),
644                declared_dependencies: BTreeMap::from([("../escape".into(), "^1.0.0".into())]),
645                ..LockedPackage::default()
646            },
647            LockedPackage {
648                name: "parent".into(),
649                version: "1.0.0".into(),
650                dep_path: "parent@1.0.0".into(),
651                peer_dependencies_meta: BTreeMap::from([(
652                    "../escape".into(),
653                    PeerDepMeta { optional: true },
654                )]),
655                ..LockedPackage::default()
656            },
657        ] {
658            let mut graph = crate::LockfileGraph::default();
659            graph.packages.insert("parent@1.0.0".into(), package);
660
661            let err = validate_dependency_aliases(Path::new("pnpm-lock.yaml"), &graph)
662                .expect_err("unsafe alias must be rejected");
663            assert!(
664                err.to_string()
665                    .contains("package parent@1.0.0 has unsafe dependency alias `../escape`"),
666                "unexpected error: {err}"
667            );
668        }
669    }
670
671    #[test]
672    fn accepts_valid_scoped_and_unscoped_dependency_aliases() {
673        let mut graph = crate::LockfileGraph::default();
674        graph.importers.insert(
675            ".".into(),
676            vec![
677                DirectDep {
678                    name: "left-pad".into(),
679                    dep_path: "left-pad@1.3.0".into(),
680                    dep_type: DepType::Production,
681                    specifier: Some("1.3.0".into()),
682                },
683                DirectDep {
684                    name: "@scope/pkg".into(),
685                    dep_path: "@scope/pkg@1.0.0".into(),
686                    dep_type: DepType::Dev,
687                    specifier: Some("1.0.0".into()),
688                },
689            ],
690        );
691        graph.packages.insert(
692            "parent@1.0.0".into(),
693            LockedPackage {
694                name: "parent".into(),
695                version: "1.0.0".into(),
696                dep_path: "parent@1.0.0".into(),
697                dependencies: BTreeMap::from([
698                    ("left-pad".into(), "1.3.0".into()),
699                    ("@scope/pkg".into(), "1.0.0".into()),
700                ]),
701                ..LockedPackage::default()
702            },
703        );
704
705        validate_dependency_aliases(Path::new("pnpm-lock.yaml"), &graph)
706            .expect("valid aliases should pass");
707    }
708
709    proptest! {
710        #[test]
711        fn dep_path_registry_version_accepts_name_at_semver(name in package_name(), version in semver()) {
712            let dep_path = format!("{name}@{version}");
713            prop_assert!(dep_path_has_registry_version(&dep_path, &name));
714        }
715
716        #[test]
717        fn dep_path_registry_version_rejects_local_source_dep_paths(
718            name in package_name(),
719            source in local_source(),
720        ) {
721            let dep_path = source.dep_path(&name);
722            prop_assert!(!dep_path_has_registry_version(&dep_path, &name));
723        }
724    }
725}
726
727/// Replace `LockfileKind::Yarn` with `LockfileKind::YarnBerry` when
728/// the yarn.lock at `path` is actually a yarn 2+ lockfile. Other
729/// kinds pass through unchanged.
730///
731/// `lockfile_candidates` only knows filenames, not content, so the
732/// yarn entry is always tagged `Yarn`. Callers that need the precise
733/// variant (install write-back, import conversions, drift logging)
734/// funnel through this helper after confirming the candidate exists.
735fn refine_yarn_kind(path: &Path, kind: LockfileKind) -> LockfileKind {
736    if kind == LockfileKind::Yarn && yarn::is_berry_path(path) {
737        LockfileKind::YarnBerry
738    } else {
739        kind
740    }
741}
742
743#[derive(Debug, thiserror::Error, miette::Diagnostic)]
744pub enum Error {
745    #[error("no lockfile found in {0}")]
746    #[diagnostic(code(ERR_AUBE_NO_LOCKFILE))]
747    NotFound(std::path::PathBuf),
748    #[error("unsupported lockfile format: {0}")]
749    #[diagnostic(code(ERR_AUBE_LOCKFILE_UNSUPPORTED_FORMAT))]
750    UnsupportedFormat(String),
751    #[error("failed to read lockfile {0}: {1}")]
752    Io(std::path::PathBuf, std::io::Error),
753    /// Structural/serialization lockfile errors that have no source
754    /// location — shape checks (`must be a mapping`), version guards
755    /// (`lockfileVersion N unsupported`), and `yaml_serde::to_string`
756    /// failures during write.
757    #[error("failed to parse lockfile {0}: {1}")]
758    #[diagnostic(code(ERR_AUBE_LOCKFILE_PARSE))]
759    Parse(std::path::PathBuf, String),
760    #[error("lockfile {0} has registry-style dependency path `{1}` backed by {2} resolution")]
761    #[diagnostic(
762        code(ERR_AUBE_RESOLUTION_SHAPE_MISMATCH),
763        help(
764            "run `aube install --no-frozen-lockfile` from a trusted manifest to regenerate the lockfile"
765        )
766    )]
767    ResolutionShapeMismatch(std::path::PathBuf, String, &'static str),
768    /// Deserialization failure with a byte offset into the source
769    /// content, so miette's `fancy` handler can draw a pointer at the
770    /// offending byte of the lockfile. Reuses `aube_manifest`'s
771    /// `ParseError` — identical shape, identical rendering — via the
772    /// same `ParseDiag` pattern `aube-workspace` uses.
773    #[error(transparent)]
774    #[diagnostic(transparent)]
775    ParseDiag(Box<aube_manifest::ParseError>),
776}
777
778/// Read a lockfile from disk, mapping I/O errors to `Error::Io`.
779pub fn read_lockfile(path: &std::path::Path) -> Result<String, Error> {
780    std::fs::read_to_string(path).map_err(|e| Error::Io(path.to_path_buf(), e))
781}
782
783/// Parse a JSON lockfile document, attaching a miette source span on
784/// failure so the fancy handler can point at the offending byte.
785pub fn parse_json<T: serde::de::DeserializeOwned>(
786    path: &std::path::Path,
787    content: String,
788) -> Result<T, Error> {
789    // sonic-rs takes an immutable &[u8], so the original `content`
790    // bytes stay intact for the serde_json fallback's diagnostic.
791    match sonic_rs::from_slice(content.as_bytes()) {
792        Ok(v) => Ok(v),
793        Err(_) => match serde_json::from_str(&content) {
794            Ok(v) => Ok(v),
795            Err(e) => Err(Error::parse_json_err(path, content, &e)),
796        },
797    }
798}
799
800impl Error {
801    pub fn parse(path: &std::path::Path, msg: impl Into<String>) -> Self {
802        Error::Parse(path.to_path_buf(), msg.into())
803    }
804
805    pub fn parse_json_err(
806        path: &std::path::Path,
807        content: String,
808        err: &serde_json::Error,
809    ) -> Self {
810        Error::ParseDiag(Box::new(aube_manifest::ParseError::from_json_err(
811            path, content, err,
812        )))
813    }
814
815    pub fn parse_yaml_err(
816        path: &std::path::Path,
817        content: String,
818        err: &yaml_serde::Error,
819    ) -> Self {
820        Error::ParseDiag(Box::new(aube_manifest::ParseError::from_yaml_err(
821            path, content, err,
822        )))
823    }
824}
825
826#[cfg(test)]
827mod parse_diag_tests {
828    use super::*;
829    use crate::{LocalSource, LockedPackage};
830    use std::path::Path;
831
832    /// Trailing `,` in an otherwise fine JSON lockfile — confirm the
833    /// helper attaches a `NamedSource` pointed at the lockfile path and
834    /// the span stays in bounds so miette can render a pointer.
835    #[test]
836    fn parse_json_attaches_span_for_bad_input() {
837        let path = Path::new("package-lock.json");
838        let content = r#"{"name":"x","#.to_string();
839        let Err(Error::ParseDiag(pe)) = parse_json::<serde_json::Value>(path, content.clone())
840        else {
841            panic!("parse_json must produce ParseDiag on malformed input");
842        };
843        let offset: usize = pe.span.offset();
844        let len: usize = pe.span.len();
845        assert!(offset + len <= content.len());
846        assert_eq!(pe.path, path);
847    }
848
849    #[test]
850    fn validate_resolution_shapes_rejects_local_source_with_registry_dep_path() {
851        let mut graph = LockfileGraph::default();
852        graph.packages.insert(
853            "left-pad@1.3.0".to_string(),
854            LockedPackage {
855                name: "left-pad".to_string(),
856                version: "1.3.0".to_string(),
857                dep_path: "left-pad@1.3.0".to_string(),
858                local_source: Some(LocalSource::Directory("vendor/left-pad".into())),
859                ..Default::default()
860            },
861        );
862
863        let err = validate_resolution_shapes(Path::new("pnpm-lock.yaml"), &graph).unwrap_err();
864        assert!(matches!(
865            err,
866            Error::ResolutionShapeMismatch(_, dep_path, "file")
867                if dep_path == "left-pad@1.3.0"
868        ));
869    }
870
871    #[test]
872    fn validate_resolution_shapes_rejects_peer_suffixed_registry_dep_path() {
873        let mut graph = LockfileGraph::default();
874        graph.packages.insert(
875            "plugin@1.0.0(react@19.0.0)".to_string(),
876            LockedPackage {
877                name: "plugin".to_string(),
878                version: "1.0.0".to_string(),
879                dep_path: "plugin@1.0.0(react@19.0.0)".to_string(),
880                local_source: Some(LocalSource::RemoteTarball(crate::RemoteTarballSource {
881                    url: "https://example.com/plugin.tgz".to_string(),
882                    integrity: "sha512-test".to_string(),
883                    git_hosted: false,
884                })),
885                ..Default::default()
886            },
887        );
888
889        let err = validate_resolution_shapes(Path::new("pnpm-lock.yaml"), &graph).unwrap_err();
890        assert!(matches!(
891            err,
892            Error::ResolutionShapeMismatch(_, dep_path, "url")
893                if dep_path == "plugin@1.0.0(react@19.0.0)"
894        ));
895    }
896
897    #[test]
898    fn validate_resolution_shapes_allows_local_source_dep_path() {
899        let source = LocalSource::Directory("vendor/left-pad".into());
900        let dep_path = source.dep_path("left-pad");
901        let mut graph = LockfileGraph::default();
902        graph.packages.insert(
903            dep_path.clone(),
904            LockedPackage {
905                name: "left-pad".to_string(),
906                version: "1.3.0".to_string(),
907                dep_path,
908                local_source: Some(source),
909                ..Default::default()
910            },
911        );
912
913        validate_resolution_shapes(Path::new("pnpm-lock.yaml"), &graph).unwrap();
914    }
915
916    /// Same story for YAML — yaml_serde reports a `Location` with a
917    /// byte index directly, so no line/col conversion is exercised
918    /// here. Both production sites (`pnpm.rs`, `yarn.rs`) call
919    /// `Error::parse_yaml_err` directly (one iterates multiple YAML
920    /// documents, the other has only borrowed content), so that's the
921    /// entry point this test locks down.
922    #[test]
923    fn parse_yaml_err_attaches_span_for_bad_input() {
924        let path = Path::new("yarn.lock");
925        let content = "packages:\n\t- pkg\n".to_string();
926        let yaml_err: yaml_serde::Error = yaml_serde::from_str::<yaml_serde::Value>(&content)
927            .expect_err("tab-indented YAML must fail");
928        let Error::ParseDiag(pe) = Error::parse_yaml_err(path, content.clone(), &yaml_err) else {
929            panic!("parse_yaml_err must produce ParseDiag");
930        };
931        let offset: usize = pe.span.offset();
932        let len: usize = pe.span.len();
933        assert!(offset + len <= content.len());
934        assert_eq!(pe.path, path);
935    }
936}
937
938#[cfg(test)]
939mod filename_tests {
940    use super::*;
941
942    #[test]
943    fn defaults_to_plain_lockfile_when_setting_absent() {
944        let dir = tempfile::tempdir().unwrap();
945        assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
946        assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.yaml");
947    }
948
949    #[test]
950    fn defaults_to_plain_lockfile_when_setting_explicit_false() {
951        let dir = tempfile::tempdir().unwrap();
952        std::fs::write(
953            dir.path().join("pnpm-workspace.yaml"),
954            "gitBranchLockfile: false\n",
955        )
956        .unwrap();
957        assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
958    }
959
960    #[test]
961    fn uses_branch_filename_when_enabled_inside_git_repo() {
962        let dir = tempfile::tempdir().unwrap();
963        std::fs::write(
964            dir.path().join("pnpm-workspace.yaml"),
965            "gitBranchLockfile: true\n",
966        )
967        .unwrap();
968        // git init + checkout a branch with a `/` so we exercise the
969        // pnpm-style `!` encoding.
970        let run = |args: &[&str]| {
971            std::process::Command::new("git")
972                .args(["-C"])
973                .arg(dir.path())
974                .args(args)
975                .output()
976                .unwrap()
977        };
978        if run(&["init", "-q"]).status.success() {
979            run(&["checkout", "-q", "-b", "feature/x"]);
980            assert_eq!(aube_lock_filename(dir.path()), "aube-lock.feature!x.yaml");
981            assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.feature!x.yaml");
982        }
983    }
984}