Skip to main content

osdk_core/backend/
npm_package.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7
8use crate::backend::aube_host::{
9    self, EmbeddedFrozenInstallRequest, EmbeddedInstallRequest, EmbeddedLockGraphRequest,
10};
11use crate::backend::{Backend, Ctx, InstallCtx};
12use crate::config::ToolConfigOrigin;
13use crate::error::{Error, Result};
14use crate::inventory::{DynamicToolBin, DynamicToolManifest};
15use crate::npm_tools::{
16    NpmInstaller, ToolScope, LOCKED_NPM_INSTALLER_OPTION, LOCKED_NPM_NATIVE_LOCK_FORMAT_OPTION,
17    LOCKED_NPM_NATIVE_LOCK_KIND_OPTION, LOCKED_NPM_NATIVE_LOCK_SHA256_OPTION,
18    LOCKED_NPM_SCOPE_OPTION,
19};
20use crate::pipeline;
21use crate::source::Source;
22use crate::tool::{InstallDependency, InstallDependencyKind, InstallIdentity, InstallScope};
23use crate::version::{ToolRequest, ToolVersion, VersionInfo};
24
25const PROVIDER: &str = "npm-package";
26const GLOBAL_INSTALL_NAMESPACE: &str = "npm-global";
27const PROJECT_DIR: &str = "project";
28const AUBE_DIR: &str = "aube";
29const AUBE_CACHE_VERSION: &str = "v1";
30const CACHE_DIR: &str = "cache";
31const AUBE_LOCKFILE_NAME: &str = "aube-lock.yaml";
32const AUBE_LOCK_FORMAT: &str = "aube-v9";
33const PROJECT_NPM_BIN_ROOT: &str = ".osdk/npm-bin";
34const PROJECT_NPM_BIN_GENERATIONS: &str = "generations";
35const PROJECT_NPM_BIN_CURRENT: &str = "current";
36const PROJECT_NPM_BIN_MANIFEST: &str = "manifest.json";
37const PROJECT_NPM_BIN_BIN_DIR: &str = "bin";
38const PROJECT_NPM_BIN_SCHEMA: u32 = 1;
39const PROJECT_NPM_BIN_MAX_JSON_BYTES: u64 = 1024 * 1024;
40const PROJECT_NPM_LOCKFILE: &str = "osdk.lock";
41const PROJECT_NPM_LOCK_MAX_BYTES: u64 = 16 * 1024 * 1024;
42const PROJECT_NPM_LAUNCHER_MAX_BYTES: u64 = 256 * 1024;
43const NPM_INSTALL_RECEIPT_FILE: &str = ".osdk-npm-receipt.json";
44const NPM_INSTALL_RECEIPT_SCHEMA: u32 = 1;
45const NPM_INSTALL_RECEIPT_MAX_BYTES: u64 = 64 * 1024;
46const NPM_PACKAGE_MANIFEST_MAX_BYTES: u64 = 1024 * 1024;
47const NPM_NATIVE_LOCK_MAX_BYTES: u64 = 16 * 1024 * 1024;
48#[cfg_attr(not(any(windows, test)), allow(dead_code))]
49const OSDK_PROJECT_NPM_CMD_MARKER: &str = ":: osdk-project-npm-bin v1";
50static NEXT_PROJECT_NPM_BIN_TEMPORARY: AtomicU64 = AtomicU64::new(0);
51
52pub const LOCKED_NPM_PACKAGE_OPTION: &str = "__osdk_npm_package";
53pub const LOCKED_NPM_LOCK_FORMAT_OPTION: &str = "__osdk_npm_lock_format";
54pub const LOCKED_NPM_LOCK_SHA256_OPTION: &str = "__osdk_npm_lock_sha256";
55pub const LOCKED_NPM_LOCKFILE_OPTION: &str = "__osdk_npm_lockfile";
56pub const LOCKED_NPM_NODE_VERSION_OPTION: &str = "__osdk_node_version";
57
58/// One resolved project npm selection recorded in the curated bin manifest.
59/// `configured_spec` comes from the already-trusted project configuration;
60/// `version` is the exact installed package version that was validated.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct ProjectNpmBinSelection {
64    pub backend: String,
65    pub configured_spec: String,
66    pub version: String,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71struct ProjectNpmBinManifest {
72    schema: u32,
73    generation: String,
74    platform: String,
75    selections: Vec<ProjectNpmBinSelection>,
76    bins: Vec<ProjectNpmBinManifestEntry>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(deny_unknown_fields)]
81struct ProjectNpmBinManifestEntry {
82    name: String,
83    backend: String,
84    target: String,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89struct ProjectNpmBinCurrent {
90    schema: u32,
91    generation: String,
92}
93
94#[derive(Debug, Deserialize)]
95struct ProjectNpmLockfile {
96    schema: u32,
97    #[serde(default)]
98    platforms: BTreeMap<String, ProjectNpmPlatformLock>,
99}
100
101#[derive(Debug, Default, Deserialize)]
102struct ProjectNpmPlatformLock {
103    #[serde(default)]
104    tools: BTreeMap<String, ProjectNpmLockedTool>,
105}
106
107#[derive(Debug, Deserialize)]
108struct ProjectNpmLockedTool {
109    request: String,
110    version: String,
111    #[serde(default)]
112    npm: Option<ProjectNpmLockedMetadata>,
113}
114
115#[derive(Debug, Deserialize)]
116struct ProjectNpmLockedMetadata {
117    package: String,
118    scope: String,
119}
120
121#[derive(Debug)]
122struct ValidatedProjectNpmBin {
123    name: String,
124    project_relative_target: PathBuf,
125}
126
127pub struct NpmPackageBackend {
128    id: String,
129    package: String,
130}
131
132#[derive(Debug)]
133struct LockedNpmGraph<'a> {
134    lockfile: &'a str,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
138struct NpmGraphIdentity {
139    sha256: String,
140    root_integrity: String,
141    root_source: String,
142    root_tarball: Option<String>,
143}
144
145/// Backend-specific evidence observed after an npm install. None of these
146/// fields select the physical install root unless the corresponding digest was
147/// already present in the request and therefore included in `InstallIdentity`.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(deny_unknown_fields)]
150pub struct NpmInstallReceipt {
151    pub schema: u32,
152    pub provider: String,
153    pub package: String,
154    pub installer: String,
155    pub node_version: String,
156    pub build_policy: String,
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub graph_sha256: Option<String>,
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub root_integrity: Option<String>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub root_source: Option<String>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub native_lock_format: Option<String>,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub native_lock_sha256: Option<String>,
167}
168
169struct UnlockedNpmResolution {
170    sources: Vec<Source>,
171    root_checksum: pipeline::Checksum,
172    root_urls: Vec<String>,
173}
174
175pub fn npm_receipt_path(install_root: &Path) -> PathBuf {
176    install_root.join(NPM_INSTALL_RECEIPT_FILE)
177}
178
179fn write_npm_receipt(install_root: &Path, receipt: &NpmInstallReceipt) -> Result<()> {
180    let path = npm_receipt_path(install_root);
181    let bytes = serde_json::to_vec_pretty(receipt)?;
182    std::fs::write(&path, bytes).map_err(|error| Error::io(path, error))
183}
184
185pub fn load_npm_receipt(install_root: &Path) -> Result<NpmInstallReceipt> {
186    let path = npm_receipt_path(install_root);
187    let bytes = crate::inventory::read_stable_regular_file(&path, NPM_INSTALL_RECEIPT_MAX_BYTES)
188        .map_err(|error| Error::io(&path, error))?;
189    let receipt: NpmInstallReceipt = serde_json::from_slice(&bytes)?;
190    if receipt.schema != NPM_INSTALL_RECEIPT_SCHEMA {
191        return Err(Error::config(format!(
192            "unsupported npm install receipt schema `{}`",
193            receipt.schema
194        )));
195    }
196    Ok(receipt)
197}
198
199#[derive(Debug, Deserialize)]
200struct IdentityLockfile {
201    importers: BTreeMap<String, IdentityImporter>,
202    packages: BTreeMap<String, IdentityPackage>,
203}
204
205#[derive(Debug, Deserialize)]
206struct IdentityImporter {
207    dependencies: BTreeMap<String, IdentityDependency>,
208}
209
210#[derive(Debug, Deserialize)]
211struct IdentityDependency {
212    specifier: String,
213    version: String,
214}
215
216#[derive(Debug, Deserialize)]
217struct IdentityPackage {
218    resolution: IdentityResolution,
219}
220
221#[derive(Debug, Deserialize)]
222struct IdentityResolution {
223    integrity: String,
224    #[serde(default)]
225    tarball: Option<String>,
226}
227
228impl NpmPackageBackend {
229    pub fn from_id(id: &str) -> Option<Self> {
230        let id = crate::inventory::canonical_dynamic_id(id).ok()?;
231        let package = id.strip_prefix("npm:")?.to_string();
232        Some(Self { id, package })
233    }
234
235    /// Complete identity for an osdk-owned npm install. Project-managed npm
236    /// packages deliberately never call this path or write this manifest.
237    pub fn install_identity(
238        &self,
239        ctx: &Ctx,
240        tv: &ToolVersion,
241        scope: ToolScope,
242    ) -> Result<InstallIdentity> {
243        let node_version = tv
244            .options
245            .get(LOCKED_NPM_NODE_VERSION_OPTION)
246            .cloned()
247            .or_else(|| selected_node_version(ctx))
248            .ok_or_else(|| Error::other(crate::t!("err.npm_dynamic_managed_node_required")))?;
249        let dependencies = vec![InstallDependency {
250            kind: InstallDependencyKind::Runtime,
251            id: "node".into(),
252            version: node_version,
253            identity: None,
254        }];
255        let mut materials = BTreeMap::new();
256        if let Some(digest) = tv.options.get(LOCKED_NPM_LOCK_SHA256_OPTION) {
257            materials.insert("lock-graph-sha256".into(), digest.to_ascii_lowercase());
258        }
259        // Compact schema-3 native-lock metadata can be reconstructed from an
260        // install after publication, so it is verification evidence rather than
261        // a locator input. Including it would make the install root change after
262        // the first successful install. The receipt binds the digest back to the
263        // observed native lock on every reuse/selection path. The legacy frozen
264        // graph digest is different: it exists only as a pre-install replay input.
265        InstallIdentity::new(
266            self.id(),
267            &tv.version,
268            ctx.platform.to_string(),
269            match scope {
270                ToolScope::Project => InstallScope::Isolated,
271                ToolScope::Global => InstallScope::Global,
272            },
273            &tv.options,
274            dependencies,
275            materials,
276        )
277    }
278
279    pub fn install_locator(
280        &self,
281        ctx: &Ctx,
282        tv: &ToolVersion,
283        scope: ToolScope,
284    ) -> Result<crate::dirs::InstallLocator> {
285        crate::dirs::InstallLocator::new(&ctx.dirs, self.install_identity(ctx, tv, scope)?)
286    }
287
288    pub fn isolated_install_root_for(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<PathBuf> {
289        Ok(self
290            .install_locator(ctx, tv, ToolScope::Project)?
291            .install_root()
292            .to_path_buf())
293    }
294
295    pub fn global_install_root_for(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<PathBuf> {
296        Ok(self
297            .install_locator(ctx, tv, ToolScope::Global)?
298            .install_root()
299            .to_path_buf())
300    }
301
302    /// Version-only npm roots are legacy detection locations. They never
303    /// authorize reuse or execution.
304    pub fn legacy_isolated_install_root(&self, ctx: &Ctx, version: &str) -> PathBuf {
305        ctx.dirs.install_path(self.id(), version)
306    }
307
308    pub fn legacy_global_install_root_path(&self, ctx: &Ctx, version: &str) -> PathBuf {
309        ctx.dirs.install_path(
310            &format!("{GLOBAL_INSTALL_NAMESPACE}:{}", self.package),
311            version,
312        )
313    }
314
315    pub fn project_dir_for(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<PathBuf> {
316        Ok(self.isolated_install_root_for(ctx, tv)?.join(PROJECT_DIR))
317    }
318
319    pub fn global_project_dir_for(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<PathBuf> {
320        Ok(self.global_install_root_for(ctx, tv)?.join(PROJECT_DIR))
321    }
322
323    /// Resolve the install root that should serve the current selection. An
324    /// explicit lock scope wins, followed by config provenance. With no scope
325    /// signal, the compatibility isolated install wins and global is a
326    /// fallback. This avoids deriving scope from a deduplicated version list.
327    pub fn selected_install_root(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Option<PathBuf>> {
328        let scope = self.selected_scope(ctx, tv)?.unwrap_or(ToolScope::Project);
329        let root = match scope {
330            ToolScope::Project => self.isolated_install_root_for(ctx, tv)?,
331            ToolScope::Global => self.global_install_root_for(ctx, tv)?,
332        };
333        self.validate_completed_install(ctx, tv, scope, &root)
334            .map(|available| available.then_some(root))
335    }
336
337    /// Resolve a path for `osdk where` using an already-resolved selection.
338    /// Explicit scope metadata on `tv` wins over config provenance; without
339    /// either signal, this preserves the historical isolated-first fallback.
340    pub fn where_install_root_for(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Option<PathBuf>> {
341        self.selected_install_root(ctx, tv)
342    }
343
344    /// List complete versions available to the scope selected by explicit
345    /// `ToolVersion` metadata or config provenance. This prevents callers
346    /// from resolving a range against the union of project and global roots.
347    pub fn list_installed_for(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<String>> {
348        Ok(self
349            .list_installed_identities_for(ctx, tv)?
350            .into_iter()
351            .map(|candidate| candidate.version)
352            .collect::<BTreeSet<_>>()
353            .into_iter()
354            .collect())
355    }
356
357    /// Return complete installed npm selections without collapsing distinct
358    /// runtime/material identities that happen to share the same package
359    /// version. Lifecycle callers must retain the selected `ToolVersion` so a
360    /// later active Node change cannot redirect `where` or `uninstall`.
361    pub fn list_installed_identities_for(
362        &self,
363        ctx: &Ctx,
364        selector: &ToolVersion,
365    ) -> Result<Vec<ToolVersion>> {
366        match self.selected_scope(ctx, selector)? {
367            Some(ToolScope::Project) => {
368                self.complete_identities_under(ctx, selector, ToolScope::Project)
369            }
370            Some(ToolScope::Global) => {
371                self.complete_identities_under(ctx, selector, ToolScope::Global)
372            }
373            None => {
374                let mut candidates =
375                    self.complete_identities_under(ctx, selector, ToolScope::Project)?;
376                candidates.extend(self.complete_identities_under(
377                    ctx,
378                    selector,
379                    ToolScope::Global,
380                )?);
381                candidates.sort_by(|left, right| {
382                    (&left.version, &left.options).cmp(&(&right.version, &right.options))
383                });
384                candidates.dedup();
385                Ok(candidates)
386            }
387        }
388    }
389
390    /// Explicitly scope an installed-version query without fabricating lock
391    /// metadata. Primarily useful for commands such as `where --global`.
392    pub fn list_installed_for_scope(&self, ctx: &Ctx, scope: ToolScope) -> Result<Vec<String>> {
393        self.list_manifest_versions(ctx, Some(scope))
394    }
395
396    /// Compatibility query for callers that only have an exact version. It
397    /// intentionally has no explicit scope signal, but can still recover one
398    /// from config provenance. New callers should retain a `ToolVersion` and
399    /// use [`Self::where_install_root_for`].
400    pub fn where_install_root(&self, ctx: &Ctx, version: &str) -> Result<Option<PathBuf>> {
401        let mut tv = ToolVersion::new(self.id(), version);
402        if let Some(request) = crate::shim::dynamic_request_from_config(ctx, self.id()) {
403            tv.options = request.options;
404        }
405        self.where_install_root_for(ctx, &tv)
406    }
407
408    /// Locate a global install, including the pre-isolation layout where a
409    /// global manifest occupied the compatibility isolated root.
410    pub fn existing_global_install_root(
411        &self,
412        ctx: &Ctx,
413        tv: &ToolVersion,
414    ) -> Result<Option<PathBuf>> {
415        let global = self.global_install_root_for(ctx, tv)?;
416        if global.join(".osdk-complete").is_file()
417            && self.validate_completed_install(ctx, tv, ToolScope::Global, &global)?
418        {
419            return Ok(Some(global));
420        }
421        Ok(None)
422    }
423
424    /// Locate a complete pre-isolation global install occupying the legacy
425    /// isolated root. Callers can use this as a compatibility source while
426    /// reinstalling into the canonical global root. The directory must not be
427    /// renamed directly because older Aube installs may contain absolute bin
428    /// symlinks rooted at the old location.
429    pub fn legacy_global_install_root(
430        &self,
431        ctx: &Ctx,
432        tv: &ToolVersion,
433    ) -> Result<Option<PathBuf>> {
434        let _ = (ctx, tv);
435        Ok(None)
436    }
437
438    /// Remove only a pre-isolation global root after a replacement global
439    /// install has been fully published. Isolated installs are never eligible.
440    pub fn remove_legacy_global_install(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<bool> {
441        let _ = (ctx, tv);
442        Ok(false)
443    }
444
445    /// Remove one global npm install without touching the isolated sibling.
446    /// The legacy location is eligible only when its manifest explicitly says
447    /// `scope = global`.
448    pub fn uninstall_global(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<bool> {
449        let canonical = self.global_install_root_for(ctx, tv)?;
450        let root = if canonical.join(".osdk-complete").is_file()
451            && self.validate_completed_install(ctx, tv, ToolScope::Global, &canonical)?
452        {
453            Some(canonical)
454        } else {
455            self.legacy_global_install_root(ctx, tv)?
456        };
457        let Some(root) = root else {
458            return Ok(false);
459        };
460        let _ = crate::inventory::remove_manifest(&root);
461        std::fs::remove_dir_all(&root).map_err(|error| Error::io(&root, error))?;
462        Ok(true)
463    }
464
465    fn selected_scope(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Option<ToolScope>> {
466        if let Some(scope) = tv.options.get(LOCKED_NPM_SCOPE_OPTION) {
467            return scope.parse().map(Some);
468        }
469        let mut matched_project = false;
470        let mut matched_global = false;
471        for (key, value) in &ctx.config.tools {
472            let matches = key == self.id()
473                || ToolRequest::parse(value).is_ok_and(|request| request.backend == self.id);
474            if !matches {
475                continue;
476            }
477            match ctx.config.tool_origins.get(key) {
478                Some(ToolConfigOrigin::ProjectConfig(_) | ToolConfigOrigin::ToolVersions(_)) => {
479                    matched_project = true;
480                }
481                Some(ToolConfigOrigin::GlobalConfig(_)) => matched_global = true,
482                None if ctx.config.global_tool_configs.contains_key(key) => matched_global = true,
483                None => {}
484            }
485        }
486        Ok(if matched_project {
487            Some(ToolScope::Project)
488        } else if matched_global {
489            Some(ToolScope::Global)
490        } else {
491            None
492        })
493    }
494
495    /// Validate a complete npm install before reuse, selection, recovery, or
496    /// execution. The identity-addressed path is necessary but not sufficient:
497    /// the receipt, package graph/native lock, published bins, and exact Node
498    /// runtime dependency must all still agree with the manifest.
499    pub fn validate_completed_install(
500        &self,
501        ctx: &Ctx,
502        tv: &ToolVersion,
503        scope: ToolScope,
504        root: &Path,
505    ) -> Result<bool> {
506        if !is_regular_file(&root.join(".osdk-complete")) {
507            return Ok(false);
508        }
509        let Some(manifest) = self.manifest_at(ctx, root, tv)? else {
510            return Ok(false);
511        };
512        let expected_scope = match scope {
513            ToolScope::Project => InstallScope::Isolated,
514            ToolScope::Global => InstallScope::Global,
515        };
516        if manifest.identity.scope != expected_scope
517            || !manifest.matches_identity(&self.install_identity(ctx, tv, scope)?)
518        {
519            return Ok(false);
520        }
521        let Some(node_version) = exact_node_dependency(&manifest.identity) else {
522            return Ok(false);
523        };
524        if !managed_node_is_runnable(ctx, node_version)? {
525            return Ok(false);
526        }
527        let receipt = match load_npm_receipt(root) {
528            Ok(receipt) => receipt,
529            Err(_) => return Ok(false),
530        };
531        if !receipt_matches_identity(self, tv, scope, &manifest.identity, &receipt)?
532            || !manifest_bins_are_confined(root, &manifest)?
533        {
534            return Ok(false);
535        }
536        match scope {
537            ToolScope::Project => validate_isolated_install_evidence(
538                root,
539                &self.package,
540                &tv.version,
541                &Self::build_policy(tv)?,
542                &receipt,
543                self.locked_graph(tv)?.as_ref(),
544            ),
545            ToolScope::Global => {
546                validate_global_install_evidence(root, &self.package, &tv.version, &receipt)
547            }
548        }
549    }
550
551    fn complete_identities_under(
552        &self,
553        ctx: &Ctx,
554        selector: &ToolVersion,
555        scope: ToolScope,
556    ) -> Result<Vec<ToolVersion>> {
557        let base = match scope {
558            ToolScope::Project => ctx
559                .dirs
560                .installs
561                .join(crate::dirs::sanitize_tool_id(self.id())),
562            ToolScope::Global => ctx
563                .dirs
564                .installs
565                .join(crate::dirs::sanitize_tool_id(&format!(
566                    "{GLOBAL_INSTALL_NAMESPACE}:{}",
567                    self.package
568                ))),
569        };
570        let mut candidates = Vec::new();
571        if !base.exists() {
572            return Ok(candidates);
573        }
574        let report = crate::inventory::scan_installs(
575            &ctx.dirs.installs,
576            &crate::inventory::ScanOptions::default(),
577        )?;
578        let expected_options =
579            crate::backend::dynamic::identity_options(self.id(), &selector.options)?;
580        for install in report.installs {
581            if !install.install_root.starts_with(&base)
582                || install.manifest.identity.tool != self.id
583                || install.manifest.identity.platform != ctx.platform.to_string()
584                || install.manifest.identity.material_options != expected_options
585                || install.manifest.identity.scope
586                    != match scope {
587                        ToolScope::Project => InstallScope::Isolated,
588                        ToolScope::Global => InstallScope::Global,
589                    }
590            {
591                continue;
592            }
593            let mut candidate = if selector
594                .options
595                .contains_key(LOCKED_NPM_NODE_VERSION_OPTION)
596            {
597                selector.clone()
598            } else {
599                let Some(candidate) = inventory_validation_candidate(&install.manifest) else {
600                    continue;
601                };
602                candidate
603            };
604            candidate.version = install.manifest.identity.version.clone();
605            candidate
606                .options
607                .insert(LOCKED_NPM_SCOPE_OPTION.into(), scope.as_str().into());
608            if self.validate_completed_install(ctx, &candidate, scope, &install.install_root)? {
609                candidates.push(candidate);
610            }
611        }
612        candidates.sort_by(|left, right| {
613            (&left.version, &left.options).cmp(&(&right.version, &right.options))
614        });
615        candidates.dedup();
616        Ok(candidates)
617    }
618
619    fn list_manifest_versions(&self, ctx: &Ctx, scope: Option<ToolScope>) -> Result<Vec<String>> {
620        let report = crate::inventory::scan_installs(
621            &ctx.dirs.installs,
622            &crate::inventory::ScanOptions::default(),
623        )?;
624        let expected_scope = scope.map(|scope| match scope {
625            ToolScope::Project => InstallScope::Isolated,
626            ToolScope::Global => InstallScope::Global,
627        });
628        let mut versions = BTreeSet::new();
629        for install in report.installs {
630            if install.manifest.identity.tool != self.id
631                || install.manifest.identity.platform != ctx.platform.to_string()
632                || !expected_scope.is_none_or(|scope| install.manifest.identity.scope == scope)
633            {
634                continue;
635            }
636            let scope = match install.manifest.identity.scope {
637                InstallScope::Isolated => ToolScope::Project,
638                InstallScope::Global => ToolScope::Global,
639                InstallScope::ProjectManaged => continue,
640            };
641            let Some(candidate) = inventory_validation_candidate(&install.manifest) else {
642                continue;
643            };
644            if self.validate_completed_install(ctx, &candidate, scope, &install.install_root)? {
645                versions.insert(candidate.version);
646            }
647        }
648        Ok(versions.into_iter().collect())
649    }
650
651    pub fn package(&self) -> &str {
652        &self.package
653    }
654
655    /// Validate that this exact package owns at least one runnable bin in a
656    /// user project. This checks package.json#bin and then validates the
657    /// corresponding launcher target instead of accepting an unrelated entry
658    /// that merely happens to exist in node_modules/.bin.
659    pub fn validate_project_package_bins(
660        &self,
661        project_dir: &Path,
662        expected_version: &str,
663    ) -> Result<Vec<String>> {
664        Ok(self
665            .validated_project_package_bins(project_dir, expected_version)?
666            .into_iter()
667            .map(|bin| bin.name)
668            .collect())
669    }
670
671    fn validated_project_package_bins(
672        &self,
673        project_dir: &Path,
674        expected_version: &str,
675    ) -> Result<Vec<ValidatedProjectNpmBin>> {
676        let package_dir = package_install_dir(project_dir, &self.package);
677        let manifest_path = package_dir.join("package.json");
678        let bytes = crate::inventory::read_stable_regular_file(
679            &manifest_path,
680            NPM_PACKAGE_MANIFEST_MAX_BYTES,
681        )
682        .map_err(|error| Error::io(&manifest_path, error))?;
683        let manifest: serde_json::Value = serde_json::from_slice(&bytes)?;
684        if manifest.get("name").and_then(serde_json::Value::as_str) != Some(&self.package)
685            || manifest.get("version").and_then(serde_json::Value::as_str) != Some(expected_version)
686        {
687            return Err(Error::other(format!(
688                "installed project package identity mismatch: expected {}@{}",
689                self.package, expected_version
690            )));
691        }
692        let entries = package_bin_entries(&manifest, &self.package)?;
693        let bin_dir = project_dir.join("node_modules/.bin");
694        let canonical_project =
695            dunce::canonicalize(project_dir).map_err(|error| Error::io(project_dir, error))?;
696        let canonical_package =
697            dunce::canonicalize(&package_dir).map_err(|error| Error::io(&package_dir, error))?;
698        if !canonical_package.starts_with(&canonical_project) {
699            return Err(Error::other(format!(
700                "installed npm package {} resolves outside project {}",
701                self.package,
702                canonical_project.display()
703            )));
704        }
705        let mut validated = Vec::with_capacity(entries.len());
706        for (name, relative_target) in &entries {
707            if relative_target.is_absolute()
708                || relative_target.as_os_str().is_empty()
709                || relative_target.components().any(|component| {
710                    matches!(
711                        component,
712                        std::path::Component::ParentDir
713                            | std::path::Component::RootDir
714                            | std::path::Component::Prefix(_)
715                    )
716                })
717            {
718                return Err(Error::other(format!(
719                    "npm package {} declares unsafe bin path {}",
720                    self.package,
721                    relative_target.display()
722                )));
723            }
724            let declared_path = package_dir.join(relative_target);
725            let declared_target = dunce::canonicalize(&declared_path)
726                .map_err(|error| Error::io(&declared_path, error))?;
727            if !declared_target.is_file() || !declared_target.starts_with(&canonical_package) {
728                return Err(Error::other(crate::t!(
729                    "err.npm_bin_outside_install_root",
730                    name = name,
731                    path = canonical_package.display()
732                )));
733            }
734            let launcher = global_bin_entry(&bin_dir, name).ok_or_else(|| {
735                Error::other(crate::t!(
736                    "err.npm_bin_target_unresolved",
737                    name = name,
738                    path = bin_dir.display()
739                ))
740            })?;
741            #[cfg(not(windows))]
742            validate_unix_project_launcher(&launcher, name, &self.package, &declared_target)?;
743            #[cfg(windows)]
744            validate_windows_project_launcher(
745                &bin_dir,
746                &launcher,
747                name,
748                &self.package,
749                &declared_target,
750            )?;
751            let project_relative_target = declared_target
752                .strip_prefix(&canonical_project)
753                .map_err(|_| {
754                    Error::other(format!(
755                        "npm package {} bin `{name}` resolves outside project {}",
756                        self.package,
757                        canonical_project.display()
758                    ))
759                })?
760                .to_path_buf();
761            validated.push(ValidatedProjectNpmBin {
762                name: name.clone(),
763                project_relative_target,
764            });
765        }
766        Ok(validated)
767    }
768
769    pub fn aube_cache_dir(ctx: &Ctx) -> PathBuf {
770        ctx.dirs
771            .cache
772            .join(AUBE_DIR)
773            .join(AUBE_CACHE_VERSION)
774            .join(CACHE_DIR)
775    }
776
777    pub fn aube_store_dir(ctx: &Ctx) -> PathBuf {
778        ctx.dirs.store.join(AUBE_DIR)
779    }
780
781    fn package_spec(&self, tv: &ToolVersion) -> String {
782        format!("{}@{}", self.package, tv.version)
783    }
784
785    fn build_policy(tv: &ToolVersion) -> Result<BuildPolicy> {
786        let Some(raw) = tv.options.get("allow_builds") else {
787            return Ok(BuildPolicy::Deny);
788        };
789        let raw = raw.trim();
790        if raw.is_empty()
791            || matches!(
792                raw.to_ascii_lowercase().as_str(),
793                "false" | "0" | "no" | "off"
794            )
795        {
796            return Ok(BuildPolicy::Deny);
797        }
798        if matches!(
799            raw.to_ascii_lowercase().as_str(),
800            "true" | "1" | "yes" | "on"
801        ) {
802            return Ok(BuildPolicy::AllowAll);
803        }
804        let mut packages = raw
805            .split(',')
806            .map(str::trim)
807            .filter(|package| !package.is_empty())
808            .map(str::to_ascii_lowercase)
809            .collect::<Vec<_>>();
810        if packages.is_empty()
811            || packages
812                .iter()
813                .any(|package| validate_npm_package_name(package).is_none())
814        {
815            return Err(Error::config(crate::t!("err.npm_allow_builds_invalid")));
816        }
817        packages.sort();
818        packages.dedup();
819        Ok(BuildPolicy::Packages(packages))
820    }
821
822    pub fn write_empty_project_manifest(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<()> {
823        Self::write_project_manifest(
824            &self.project_dir_for(ctx, tv)?,
825            None,
826            &Self::build_policy(tv)?,
827        )
828    }
829
830    fn write_project_manifest(
831        project_dir: &Path,
832        dependency: Option<(&str, &str)>,
833        build_policy: &BuildPolicy,
834    ) -> Result<()> {
835        std::fs::create_dir_all(project_dir).map_err(|error| Error::io(project_dir, error))?;
836        let mut manifest = serde_json::json!({
837            "name": "osdk-dynamic-npm-tool",
838            "private": true
839        });
840        if let Some((package, version)) = dependency {
841            manifest["dependencies"] = serde_json::Value::Object(
842                [(
843                    package.to_string(),
844                    serde_json::Value::String(version.to_string()),
845                )]
846                .into_iter()
847                .collect(),
848            );
849        }
850        if let BuildPolicy::Packages(packages) = build_policy {
851            let allow_builds = packages
852                .iter()
853                .map(|package| (package.clone(), serde_json::Value::Bool(true)))
854                .collect::<serde_json::Map<_, _>>();
855            manifest["aube"] = serde_json::json!({ "allowBuilds": allow_builds });
856        }
857        let package_json = project_dir.join("package.json");
858        let bytes = serde_json::to_vec_pretty(&manifest)?;
859        std::fs::write(&package_json, bytes).map_err(|error| Error::io(&package_json, error))
860    }
861
862    fn locked_graph<'a>(&self, tv: &'a ToolVersion) -> Result<Option<LockedNpmGraph<'a>>> {
863        let values = [
864            tv.options.get(LOCKED_NPM_PACKAGE_OPTION),
865            tv.options.get(LOCKED_NPM_LOCK_FORMAT_OPTION),
866            tv.options.get(LOCKED_NPM_LOCK_SHA256_OPTION),
867            tv.options.get(LOCKED_NPM_LOCKFILE_OPTION),
868        ];
869        // Schema 3 intentionally carries package/installer/scope metadata
870        // without the old frozen graph payload. Package identity alone must
871        // therefore not opt into the legacy graph reader.
872        if values[1..].iter().all(|value| value.is_none()) {
873            return Ok(None);
874        }
875
876        let required = |key, value: Option<&'a String>| {
877            value.map(String::as_str).ok_or_else(|| {
878                Error::other(crate::t!("err.npm_lock_graph_option_missing", key = key))
879            })
880        };
881        let package = required(LOCKED_NPM_PACKAGE_OPTION, values[0])?;
882        let format = required(LOCKED_NPM_LOCK_FORMAT_OPTION, values[1])?;
883        let sha256 = required(LOCKED_NPM_LOCK_SHA256_OPTION, values[2])?;
884        let lockfile = required(LOCKED_NPM_LOCKFILE_OPTION, values[3])?;
885
886        if tv.backend != self.id || package != self.package {
887            return Err(Error::other(crate::t!(
888                "err.npm_lock_graph_identity_mismatch",
889                expected_tool = self.id,
890                expected_package = self.package,
891                actual_tool = tv.backend,
892                actual_package = package
893            )));
894        }
895        if format != AUBE_LOCK_FORMAT {
896            return Err(Error::other(crate::t!(
897                "err.npm_lock_graph_format_unsupported",
898                format = format,
899                tool = self.id,
900                expected = AUBE_LOCK_FORMAT
901            )));
902        }
903        if sha256.len() != 64
904            || !sha256
905                .bytes()
906                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
907        {
908            return Err(Error::other(crate::t!(
909                "err.npm_lock_graph_digest_invalid",
910                tool = self.id
911            )));
912        }
913        let actual = pipeline::verify::hash_bytes(lockfile.as_bytes(), pipeline::HashAlgo::Sha256);
914        if actual != sha256 {
915            return Err(Error::ChecksumMismatch {
916                name: crate::t!("label.npm_lock_graph", tool = self.id),
917                expected: sha256.to_string(),
918                actual,
919            });
920        }
921
922        Ok(Some(LockedNpmGraph { lockfile }))
923    }
924
925    fn validate_compact_lock_metadata(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<()> {
926        // Scope alone is also used as an in-process routing hint by explicit
927        // compatibility commands; it is not compact lock metadata.
928        let metadata_present = tv.options.contains_key(LOCKED_NPM_PACKAGE_OPTION)
929            || tv
930                .options
931                .contains_key(crate::npm_tools::LOCKED_NPM_INSTALLER_OPTION)
932            || tv
933                .options
934                .contains_key(crate::npm_tools::LOCKED_NPM_NATIVE_LOCK_KIND_OPTION)
935            || tv
936                .options
937                .contains_key(crate::npm_tools::LOCKED_NPM_NATIVE_LOCK_FORMAT_OPTION)
938            || tv
939                .options
940                .contains_key(crate::npm_tools::LOCKED_NPM_NATIVE_LOCK_SHA256_OPTION);
941        if !metadata_present || tv.options.contains_key(LOCKED_NPM_LOCKFILE_OPTION) {
942            return Ok(());
943        }
944        let package = tv
945            .options
946            .get(LOCKED_NPM_PACKAGE_OPTION)
947            .ok_or_else(|| Error::other("compact npm lock metadata is missing package identity"))?;
948        if package != &self.package {
949            return Err(Error::other(format!(
950                "compact npm lock package mismatch: expected {}, found {package}",
951                self.package
952            )));
953        }
954        let scope = tv.options.get(crate::npm_tools::LOCKED_NPM_SCOPE_OPTION);
955        let installer = tv
956            .options
957            .get(crate::npm_tools::LOCKED_NPM_INSTALLER_OPTION)
958            .ok_or_else(|| Error::other("compact npm lock metadata is missing installer"))?;
959        let root = match scope.map(String::as_str) {
960            Some("global") => self
961                .existing_global_install_root(ctx, tv)?
962                .unwrap_or(self.global_install_root_for(ctx, tv)?),
963            Some("project") => return Ok(()),
964            _ => return Err(Error::other("compact npm lock metadata has invalid scope")),
965        };
966        let manifest = DynamicToolManifest::load(&root)?;
967        let receipt = load_npm_receipt(&root)?;
968        if manifest.identity.scope != InstallScope::Global || receipt.installer != *installer {
969            return Err(Error::other(format!(
970                "global npm install metadata does not match the lock for {}@{}",
971                self.id, tv.version
972            )));
973        }
974        let keys = [
975            crate::npm_tools::LOCKED_NPM_NATIVE_LOCK_KIND_OPTION,
976            crate::npm_tools::LOCKED_NPM_NATIVE_LOCK_FORMAT_OPTION,
977            crate::npm_tools::LOCKED_NPM_NATIVE_LOCK_SHA256_OPTION,
978        ];
979        let present = keys
980            .iter()
981            .map(|key| tv.options.get(*key))
982            .collect::<Vec<_>>();
983        if present.iter().all(|value| value.is_none()) {
984            return Ok(());
985        }
986        if present.iter().any(|value| value.is_none()) {
987            return Err(Error::other(
988                "compact npm native-lock metadata is incomplete",
989            ));
990        }
991        let expected_format = present[1].expect("validated above");
992        let expected_digest = present[2].expect("validated above");
993        if receipt.native_lock_format.as_ref() != Some(expected_format)
994            || receipt.native_lock_sha256.as_ref() != Some(expected_digest)
995        {
996            return Err(Error::other(format!(
997                "global npm native lock metadata does not match the lock for {}@{}",
998                self.id, tv.version
999            )));
1000        }
1001        Ok(())
1002    }
1003
1004    fn restore_locked_project(
1005        &self,
1006        project_dir: &Path,
1007        tv: &ToolVersion,
1008        build_policy: &BuildPolicy,
1009        graph: &LockedNpmGraph<'_>,
1010    ) -> Result<()> {
1011        Self::write_project_manifest(
1012            project_dir,
1013            Some((&self.package, &tv.version)),
1014            build_policy,
1015        )?;
1016        let lockfile_path = project_dir.join(AUBE_LOCKFILE_NAME);
1017        std::fs::write(&lockfile_path, graph.lockfile.as_bytes())
1018            .map_err(|error| Error::io(lockfile_path, error))
1019    }
1020
1021    pub async fn prepare_lock_graph(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<PathBuf> {
1022        if tv.backend != self.id {
1023            return Err(Error::other(crate::t!(
1024                "err.npm_lock_graph_tool_mismatch",
1025                expected = self.id,
1026                actual = tv.backend
1027            )));
1028        }
1029        let lock_path = ctx.dirs.lock_dir(self.id()).join(format!(
1030            "{}.lock",
1031            crate::dirs::sanitize_version_component(&tv.version)
1032        ));
1033        let _lock = crate::lock::FileLock::acquire(lock_path)?;
1034        let sources = crate::source::select::ranked_source_list(ctx, self).await?;
1035        let source = sources.first().ok_or_else(|| Error::NoUsableSource {
1036            tool: self.id().to_string(),
1037            tried: 0,
1038        })?;
1039        let project_dir = self.project_dir_for(ctx, tv)?;
1040        let build_policy = Self::build_policy(tv)?;
1041        Self::write_project_manifest(
1042            &project_dir,
1043            Some((&self.package, &tv.version)),
1044            &build_policy,
1045        )?;
1046        Self::write_project_npmrc(&project_dir, Some(&source.download_url))?;
1047        aube_host::prepare_lock_graph(EmbeddedLockGraphRequest {
1048            project_dir: &project_dir,
1049            cache_dir: Self::aube_cache_dir(ctx),
1050            store_dir: Self::aube_store_dir(ctx),
1051            node_bin_dir: managed_node(ctx, tv)?.0,
1052            registry: Some(source.download_url.clone()),
1053            offline: ctx.config.settings.offline,
1054        })
1055        .await?;
1056        let lockfile_path = project_dir.join(AUBE_LOCKFILE_NAME);
1057        if !lockfile_path.is_file() {
1058            return Err(Error::other(crate::t!(
1059                "err.npm_lock_graph_not_produced",
1060                package = self.package,
1061                version = tv.version
1062            )));
1063        }
1064        Ok(lockfile_path)
1065    }
1066
1067    fn write_project_npmrc(project_dir: &Path, url: Option<&str>) -> Result<()> {
1068        let npmrc = project_dir.join(".npmrc");
1069        if let Some(url) = url {
1070            let contents = format!("registry={url}\n");
1071            std::fs::write(&npmrc, contents).map_err(|error| Error::io(&npmrc, error))?;
1072        } else if npmrc.exists() {
1073            std::fs::remove_file(&npmrc).map_err(|error| Error::io(&npmrc, error))?;
1074        }
1075        Ok(())
1076    }
1077
1078    fn validate_install_layout(project_dir: &Path, package: &str) -> Result<PathBuf> {
1079        let package_dir = package_install_dir(project_dir, package);
1080        if !package_dir.is_dir() {
1081            return Err(Error::other(crate::t!(
1082                "err.npm_install_package_missing",
1083                package = package,
1084                path = project_dir.display()
1085            )));
1086        }
1087        let bin_dir = project_dir.join("node_modules").join(".bin");
1088        if !bin_dir.is_dir() {
1089            return Err(Error::other(crate::t!(
1090                "err.npm_install_bin_dir_missing",
1091                package = package
1092            )));
1093        }
1094        Ok(bin_dir)
1095    }
1096
1097    #[allow(clippy::too_many_arguments)]
1098    fn build_manifest(
1099        &self,
1100        ctx: &Ctx,
1101        tv: &ToolVersion,
1102        install_root: &Path,
1103        bin_dir: &Path,
1104        node_version: &str,
1105        build_policy: &BuildPolicy,
1106        graph_identity: &NpmGraphIdentity,
1107    ) -> Result<DynamicToolManifest> {
1108        let identity = self.install_identity(ctx, tv, ToolScope::Project)?;
1109        let mut manifest = DynamicToolManifest::from_identity(identity)?;
1110        manifest.bins = discover_bins(install_root, bin_dir)?;
1111        write_npm_receipt(
1112            install_root,
1113            &NpmInstallReceipt {
1114                schema: NPM_INSTALL_RECEIPT_SCHEMA,
1115                provider: PROVIDER.into(),
1116                package: self.package.clone(),
1117                installer: "aube".into(),
1118                node_version: node_version.into(),
1119                build_policy: build_policy.identity(),
1120                graph_sha256: Some(graph_identity.sha256.clone()),
1121                root_integrity: Some(graph_identity.root_integrity.clone()),
1122                root_source: Some(graph_identity.root_source.clone()),
1123                native_lock_format: None,
1124                native_lock_sha256: None,
1125            },
1126        )?;
1127        manifest.normalize()
1128    }
1129
1130    /// Validate a synthetic-project install produced by an explicitly managed
1131    /// npm-compatible installer and publish the common dynamic inventory.
1132    ///
1133    /// The caller owns the package-manager invocation and its native lock. This
1134    /// method deliberately reuses the same confined bin-target validation as
1135    /// embedded Aube installs, so native npm/pnpm cannot publish paths outside
1136    /// the osdk install root.
1137    pub fn finalize_global_install(
1138        &self,
1139        ctx: &Ctx,
1140        tv: &ToolVersion,
1141        bin_dir: &Path,
1142        node_version: &str,
1143        installer: &str,
1144        native_lock: Option<(&str, &str)>,
1145    ) -> Result<DynamicToolManifest> {
1146        let install_root = self.global_install_root_for(ctx, tv)?;
1147        self.finalize_global_install_at(
1148            ctx,
1149            tv,
1150            &install_root,
1151            bin_dir,
1152            node_version,
1153            installer,
1154            native_lock,
1155        )
1156    }
1157
1158    /// Validate and finalize a global install assembled in an arbitrary root.
1159    /// This lets callers publish through a staging directory: package/bin
1160    /// validation, inventory creation, and the completion marker all happen
1161    /// before the staging directory is swapped into its final path.
1162    #[allow(clippy::too_many_arguments)]
1163    pub fn finalize_global_install_at(
1164        &self,
1165        ctx: &Ctx,
1166        tv: &ToolVersion,
1167        install_root: &Path,
1168        bin_dir: &Path,
1169        node_version: &str,
1170        installer: &str,
1171        native_lock: Option<(&str, &str)>,
1172    ) -> Result<DynamicToolManifest> {
1173        let package_dir = if installer == "npm" {
1174            #[cfg(windows)]
1175            let modules = install_root.join("node_modules");
1176            #[cfg(not(windows))]
1177            let modules = install_root.join("lib/node_modules");
1178            modules.join(&self.package)
1179        } else if installer == "aube" {
1180            package_install_dir(&install_root.join(PROJECT_DIR), &self.package)
1181        } else {
1182            install_root.to_path_buf()
1183        };
1184        if !package_dir.is_dir() {
1185            return Err(Error::other(crate::t!(
1186                "err.npm_install_package_missing",
1187                package = self.package,
1188                path = install_root.display()
1189            )));
1190        }
1191        let identity = self.install_identity(ctx, tv, ToolScope::Global)?;
1192        let mut manifest = DynamicToolManifest::from_identity(identity)?;
1193        manifest.bins = discover_global_bins(install_root, bin_dir)?;
1194        write_npm_receipt(
1195            install_root,
1196            &NpmInstallReceipt {
1197                schema: NPM_INSTALL_RECEIPT_SCHEMA,
1198                provider: PROVIDER.into(),
1199                package: self.package.clone(),
1200                installer: installer.into(),
1201                node_version: node_version.into(),
1202                build_policy: Self::build_policy(tv)?.identity(),
1203                graph_sha256: None,
1204                root_integrity: None,
1205                root_source: None,
1206                native_lock_format: native_lock.map(|value| value.0.to_string()),
1207                native_lock_sha256: native_lock.map(|value| value.1.to_string()),
1208            },
1209        )?;
1210        let manifest = manifest.normalize()?;
1211        manifest.write_atomic(install_root)?;
1212        std::fs::write(install_root.join(".osdk-complete"), b"")
1213            .map_err(|error| Error::io(install_root.join(".osdk-complete"), error))?;
1214        Ok(manifest)
1215    }
1216
1217    fn manifest_at(
1218        &self,
1219        ctx: &Ctx,
1220        install_root: &Path,
1221        tv: &ToolVersion,
1222    ) -> Result<Option<DynamicToolManifest>> {
1223        let path = DynamicToolManifest::manifest_path(install_root);
1224        if !path.is_file() {
1225            return Ok(None);
1226        }
1227        let manifest = DynamicToolManifest::load(install_root)?;
1228        let scope = match manifest.identity.scope {
1229            InstallScope::Isolated => ToolScope::Project,
1230            InstallScope::Global => ToolScope::Global,
1231            InstallScope::ProjectManaged => return Ok(None),
1232        };
1233        if !manifest.matches_identity(&self.install_identity(ctx, tv, scope)?) {
1234            return Err(Error::other(format!(
1235                "npm inventory identity mismatch at {}",
1236                path.display()
1237            )));
1238        }
1239        Ok(Some(manifest))
1240    }
1241}
1242
1243#[async_trait]
1244impl Backend for NpmPackageBackend {
1245    fn id(&self) -> &str {
1246        &self.id
1247    }
1248
1249    fn default_sources(&self) -> Vec<Source> {
1250        crate::backend::npm_cli::NpmBackend.default_sources()
1251    }
1252
1253    fn probe_url(&self, ctx: &Ctx, source: &Source) -> Option<String> {
1254        crate::backend::npm_cli::NpmBackend.probe_url(ctx, source)
1255    }
1256
1257    async fn list_remote_versions(&self, ctx: &Ctx) -> Result<Vec<VersionInfo>> {
1258        let sources = crate::source::select::ranked_source_list(ctx, self).await?;
1259        let versions = crate::npm::list_versions(ctx, &sources, &self.package).await?;
1260        Ok(versions
1261            .into_iter()
1262            .map(|version| VersionInfo {
1263                stable: !version.contains('-'),
1264                version,
1265                lts: None,
1266            })
1267            .collect())
1268    }
1269
1270    async fn resolve_version(&self, ctx: &Ctx, req: &ToolRequest) -> Result<ToolVersion> {
1271        crate::backend::dynamic::validate_options(self.id(), &req.options)?;
1272        let sources = crate::source::select::ranked_source_list(ctx, self).await?;
1273        let mut version =
1274            crate::npm::resolve_package_version(ctx, &sources, &self.package, self.id(), req)
1275                .await?;
1276        if let Some(node_version) = selected_node_version(ctx) {
1277            version
1278                .options
1279                .entry(LOCKED_NPM_NODE_VERSION_OPTION.into())
1280                .or_insert(node_version);
1281        }
1282        Ok(version)
1283    }
1284
1285    async fn install(&self, ictx: &InstallCtx<'_>, tv: &ToolVersion) -> Result<()> {
1286        let ctx = ictx.ctx;
1287        self.validate_compact_lock_metadata(ctx, tv)?;
1288        let locator = self.install_locator(ctx, tv, ToolScope::Project)?;
1289        let install_root = locator.install_root().to_path_buf();
1290        let project_dir = install_root.join(PROJECT_DIR);
1291        let locked_graph = self.locked_graph(tv)?;
1292        let build_policy = Self::build_policy(tv)?;
1293        if ctx.config.settings.offline
1294            && locked_graph.is_none()
1295            && !install_root.join(".osdk-complete").is_file()
1296        {
1297            return Err(Error::other(crate::t!(
1298                "err.npm_offline_lock_graph_required",
1299                tool = self.id,
1300                version = tv.version
1301            )));
1302        }
1303        let (node_bin_dir, node_version) = managed_node(ctx, tv)?;
1304        if install_matches(
1305            &install_root,
1306            locator.identity(),
1307            &self.package,
1308            locked_graph.as_ref(),
1309            &build_policy,
1310        )? {
1311            return Ok(());
1312        }
1313        let _lock = crate::lock::FileLock::acquire(locator.lock_path())?;
1314        if install_matches(
1315            &install_root,
1316            locator.identity(),
1317            &self.package,
1318            locked_graph.as_ref(),
1319            &build_policy,
1320        )? {
1321            return Ok(());
1322        }
1323        if ctx.config.settings.offline && locked_graph.is_none() {
1324            return Err(Error::other(crate::t!(
1325                "err.npm_offline_lock_graph_required",
1326                tool = self.id,
1327                version = tv.version
1328            )));
1329        }
1330        let unlocked_resolution = if locked_graph.is_none() {
1331            let sources = crate::source::select::ranked_source_list(ctx, self).await?;
1332            let dist = crate::npm::resolve_dist(ctx, &sources, &self.package, &tv.version).await?;
1333            let root_checksum = dist.checksum.ok_or_else(|| {
1334                Error::other(crate::t!(
1335                    "err.npm_package_sri_missing",
1336                    package = self.package,
1337                    version = tv.version
1338                ))
1339            })?;
1340            Some(UnlockedNpmResolution {
1341                sources,
1342                root_checksum,
1343                root_urls: dist.urls,
1344            })
1345        } else {
1346            None
1347        };
1348        if install_root.exists() {
1349            let _ = std::fs::remove_dir_all(&install_root);
1350        }
1351        std::fs::create_dir_all(&install_root).map_err(|error| Error::io(&install_root, error))?;
1352
1353        if let Some(graph) = locked_graph.as_ref() {
1354            self.restore_locked_project(&project_dir, tv, &build_policy, graph)?;
1355            Self::write_project_npmrc(&project_dir, None)?;
1356            let request = EmbeddedFrozenInstallRequest {
1357                project_dir: &project_dir,
1358                cache_dir: Self::aube_cache_dir(ctx),
1359                store_dir: Self::aube_store_dir(ctx),
1360                node_bin_dir,
1361                registry: None,
1362                scripts_enabled: !matches!(build_policy, BuildPolicy::Deny),
1363                dangerously_allow_all_builds: matches!(build_policy, BuildPolicy::AllowAll),
1364                offline: ctx.config.settings.offline,
1365            };
1366            if let Err(error) = aube_host::install_frozen(request).await {
1367                let _ = std::fs::remove_dir_all(&install_root);
1368                return Err(error);
1369            }
1370        } else {
1371            let resolution = unlocked_resolution
1372                .as_ref()
1373                .expect("unlocked npm resolution is prepared before mutating the install root");
1374            let package_spec = self.package_spec(tv);
1375            let mut last_error = None;
1376            for source in &resolution.sources {
1377                if install_root.exists() {
1378                    let _ = std::fs::remove_dir_all(&install_root);
1379                }
1380                std::fs::create_dir_all(&install_root)
1381                    .map_err(|error| Error::io(&install_root, error))?;
1382                Self::write_project_manifest(&project_dir, None, &build_policy)?;
1383                Self::write_project_npmrc(&project_dir, Some(&source.download_url))?;
1384                let request = EmbeddedInstallRequest {
1385                    project_dir: &project_dir,
1386                    packages: std::slice::from_ref(&package_spec),
1387                    cache_dir: Self::aube_cache_dir(ctx),
1388                    store_dir: Self::aube_store_dir(ctx),
1389                    node_bin_dir: node_bin_dir.clone(),
1390                    registry: Some(source.download_url.clone()),
1391                    scripts_enabled: !matches!(build_policy, BuildPolicy::Deny),
1392                    dangerously_allow_all_builds: matches!(build_policy, BuildPolicy::AllowAll),
1393                    offline: false,
1394                };
1395                match aube_host::install_packages(request).await {
1396                    Ok(()) => {
1397                        last_error = None;
1398                        break;
1399                    }
1400                    Err(error) => {
1401                        last_error = Some(error);
1402                    }
1403                }
1404            }
1405            if let Some(error) = last_error {
1406                let _ = std::fs::remove_dir_all(&install_root);
1407                return Err(error);
1408            }
1409        }
1410
1411        let bin_dir = match Self::validate_install_layout(&project_dir, &self.package) {
1412            Ok(bin_dir) => bin_dir,
1413            Err(error) => {
1414                let _ = std::fs::remove_dir_all(&install_root);
1415                return Err(error);
1416            }
1417        };
1418        let graph_identity = match npm_graph_identity(&project_dir, &self.package, &tv.version) {
1419            Ok(identity) => identity,
1420            Err(error) => {
1421                let _ = std::fs::remove_dir_all(&install_root);
1422                return Err(error);
1423            }
1424        };
1425        if let Some(graph) = locked_graph.as_ref() {
1426            let expected =
1427                pipeline::verify::hash_bytes(graph.lockfile.as_bytes(), pipeline::HashAlgo::Sha256);
1428            if graph_identity.sha256 != expected {
1429                let _ = std::fs::remove_dir_all(&install_root);
1430                return Err(Error::ChecksumMismatch {
1431                    name: crate::t!("label.npm_lock_graph", tool = self.id),
1432                    expected,
1433                    actual: graph_identity.sha256,
1434                });
1435            }
1436        } else if let Some(resolution) = unlocked_resolution.as_ref() {
1437            if let Err(error) = validate_unlocked_graph_identity(
1438                &graph_identity,
1439                &resolution.root_checksum,
1440                &resolution.root_urls,
1441                &self.package,
1442                &tv.version,
1443            ) {
1444                let _ = std::fs::remove_dir_all(&install_root);
1445                return Err(error);
1446            }
1447        }
1448        let manifest = match self.build_manifest(
1449            ctx,
1450            tv,
1451            &install_root,
1452            &bin_dir,
1453            &node_version,
1454            &build_policy,
1455            &graph_identity,
1456        ) {
1457            Ok(manifest) => manifest,
1458            Err(error) => {
1459                let _ = std::fs::remove_dir_all(&install_root);
1460                return Err(error);
1461            }
1462        };
1463        manifest.write_atomic(&install_root)?;
1464        std::fs::write(install_root.join(".osdk-complete"), b"")
1465            .map_err(|error| Error::io(install_root.join(".osdk-complete"), error))?;
1466        Ok(())
1467    }
1468
1469    async fn uninstall(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<()> {
1470        let install_root = self.isolated_install_root_for(ctx, tv)?;
1471        if !install_root.exists() {
1472            return Ok(());
1473        }
1474        let _ = crate::inventory::remove_manifest(&install_root);
1475        std::fs::remove_dir_all(&install_root).map_err(|error| Error::io(&install_root, error))
1476    }
1477
1478    fn list_installed(&self, ctx: &Ctx) -> Result<Vec<String>> {
1479        self.list_manifest_versions(ctx, None)
1480    }
1481
1482    fn bin_paths(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<PathBuf>> {
1483        let Some(install_root) = self.selected_install_root(ctx, tv)? else {
1484            return Ok(Vec::new());
1485        };
1486        if let Some(manifest) = self.manifest_at(ctx, &install_root, tv)? {
1487            return Ok(manifest
1488                .bins
1489                .iter()
1490                .filter_map(|bin| install_root.join(&bin.path).parent().map(Path::to_path_buf))
1491                .collect::<BTreeSet<_>>()
1492                .into_iter()
1493                .collect());
1494        }
1495        Ok(vec![install_root
1496            .join(PROJECT_DIR)
1497            .join("node_modules/.bin")])
1498    }
1499
1500    fn exec_env(&self, ctx: &Ctx, _tv: &ToolVersion) -> Result<BTreeMap<String, String>> {
1501        let mut env = crate::cache::manager_exec_env(
1502            &ctx.dirs.cache,
1503            &[
1504                ("npm_config_cache", "npm"),
1505                ("npm_config_store_dir", "npm-store"),
1506            ],
1507        );
1508        env.insert(
1509            "npm_config_cache".into(),
1510            Self::aube_cache_dir(ctx).display().to_string(),
1511        );
1512        env.insert(
1513            "npm_config_store_dir".into(),
1514            Self::aube_store_dir(ctx).display().to_string(),
1515        );
1516        Ok(env)
1517    }
1518
1519    fn bin_names(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<String>> {
1520        if let Some(install_root) = self.selected_install_root(ctx, tv)? {
1521            if let Some(manifest) = self.manifest_at(ctx, &install_root, tv)? {
1522                return Ok(manifest.bins.into_iter().map(|bin| bin.name).collect());
1523            }
1524        }
1525        let paths = self.bin_paths(ctx, tv)?;
1526        let mut names = Vec::new();
1527        for path in paths {
1528            names.extend(discover_bin_names(&path)?);
1529        }
1530        names.sort();
1531        names.dedup();
1532        if names.is_empty() {
1533            return Err(Error::other(crate::t!(
1534                "err.npm_dynamic_no_validated_executables",
1535                tool = self.id()
1536            )));
1537        }
1538        Ok(names)
1539    }
1540
1541    fn dynamic_install_identity(
1542        &self,
1543        ctx: &Ctx,
1544        tv: &ToolVersion,
1545    ) -> Result<Option<InstallIdentity>> {
1546        let scope = self.selected_scope(ctx, tv)?.unwrap_or(ToolScope::Project);
1547        self.install_identity(ctx, tv, scope).map(Some)
1548    }
1549
1550    fn validate_dynamic_install(
1551        &self,
1552        ctx: &Ctx,
1553        tv: &ToolVersion,
1554        install_root: &Path,
1555        identity: &InstallIdentity,
1556    ) -> Result<bool> {
1557        let Some(node_version) = exact_node_dependency(identity) else {
1558            return Ok(false);
1559        };
1560        if !managed_node_is_runnable(ctx, node_version)? {
1561            return Err(Error::other(crate::t!(
1562                "err.shim_managed_node_required",
1563                tool = self.id()
1564            )));
1565        }
1566        let scope = match identity.scope {
1567            InstallScope::Isolated => ToolScope::Project,
1568            InstallScope::Global => ToolScope::Global,
1569            InstallScope::ProjectManaged => return Ok(false),
1570        };
1571        self.validate_completed_install(ctx, tv, scope, install_root)
1572    }
1573}
1574
1575fn validate_npm_package_name(package: &str) -> Option<()> {
1576    if let Some(rest) = package.strip_prefix('@') {
1577        let (scope, name) = rest.split_once('/')?;
1578        if !valid_npm_segment(scope) || !valid_npm_segment(name) || name.contains('/') {
1579            return None;
1580        }
1581        return Some(());
1582    }
1583    if !valid_npm_segment(package) {
1584        return None;
1585    }
1586    Some(())
1587}
1588
1589#[derive(Debug, Clone, PartialEq, Eq)]
1590enum BuildPolicy {
1591    Deny,
1592    Packages(Vec<String>),
1593    AllowAll,
1594}
1595
1596impl BuildPolicy {
1597    fn identity(&self) -> String {
1598        match self {
1599            Self::Deny => "deny".into(),
1600            Self::AllowAll => "allow-all".into(),
1601            Self::Packages(packages) => format!("packages:{}", packages.join(",")),
1602        }
1603    }
1604}
1605
1606fn managed_node(ctx: &Ctx, npm_tool: &ToolVersion) -> Result<(PathBuf, String)> {
1607    let node = crate::backend::node::NodeBackend;
1608    let requested = npm_tool
1609        .options
1610        .get(LOCKED_NPM_NODE_VERSION_OPTION)
1611        .cloned()
1612        .or_else(|| selected_node_version(ctx));
1613    let version = requested
1614        .as_ref()
1615        .ok_or_else(|| Error::other(crate::t!("err.npm_dynamic_managed_node_required")))?;
1616    let tool = ToolVersion::new("node", version);
1617    let bin = node
1618        .bin_paths(ctx, &tool)?
1619        .into_iter()
1620        .find(|path| path.join(node_executable_name()).is_file())
1621        .ok_or_else(|| {
1622            Error::other(crate::t!(
1623                "err.managed_node_bin_dir_missing",
1624                version = version
1625            ))
1626        })?;
1627    Ok((bin, version.clone()))
1628}
1629
1630fn selected_node_version(ctx: &Ctx) -> Option<String> {
1631    let node = crate::backend::node::NodeBackend;
1632    let versions = node.list_installed(ctx).ok()?;
1633    if versions.is_empty() {
1634        return None;
1635    }
1636    ctx.config
1637        .tools
1638        .get("node")
1639        .and_then(|spec| {
1640            let infos = versions
1641                .iter()
1642                .map(crate::version::VersionInfo::stable)
1643                .collect::<Vec<_>>();
1644            crate::version::select_version(&crate::version::VersionSpec::parse(spec), &infos)
1645                .map(|version| version.version.clone())
1646        })
1647        .or_else(|| {
1648            versions.into_iter().max_by(|left, right| {
1649                crate::backend::python::cmp_versions(left, right).then_with(|| left.cmp(right))
1650            })
1651        })
1652}
1653
1654fn inventory_validation_candidate(manifest: &DynamicToolManifest) -> Option<ToolVersion> {
1655    let mut candidate = ToolVersion::new(&manifest.identity.tool, &manifest.identity.version);
1656    candidate.options = manifest.identity.material_options.clone();
1657    candidate.options.insert(
1658        LOCKED_NPM_NODE_VERSION_OPTION.into(),
1659        exact_node_dependency(&manifest.identity)?.to_string(),
1660    );
1661    if let Some(digest) = manifest.identity.materials.get("lock-graph-sha256") {
1662        candidate
1663            .options
1664            .insert(LOCKED_NPM_LOCK_SHA256_OPTION.into(), digest.clone());
1665    }
1666    Some(candidate)
1667}
1668
1669pub(crate) fn exact_node_dependency(identity: &InstallIdentity) -> Option<&str> {
1670    let mut dependencies = identity.dependencies.iter().filter(|dependency| {
1671        dependency.kind == InstallDependencyKind::Runtime && dependency.id == "node"
1672    });
1673    let dependency = dependencies.next()?;
1674    dependencies
1675        .next()
1676        .is_none()
1677        .then_some(dependency.version.as_str())
1678}
1679
1680pub(crate) fn managed_node_is_runnable(ctx: &Ctx, version: &str) -> Result<bool> {
1681    let node = crate::backend::node::NodeBackend;
1682    let tool = ToolVersion::new("node", version);
1683    Ok(node.bin_paths(ctx, &tool)?.into_iter().any(|directory| {
1684        is_runnable_file(&directory.join(node_executable_name()))
1685            && is_regular_file(
1686                &ctx.dirs
1687                    .install_path("node", version)
1688                    .join(".osdk-complete"),
1689            )
1690    }))
1691}
1692
1693fn is_regular_file(path: &Path) -> bool {
1694    std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_file())
1695}
1696
1697#[cfg(unix)]
1698fn is_runnable_file(path: &Path) -> bool {
1699    use std::os::unix::fs::PermissionsExt;
1700
1701    is_regular_file(path)
1702        && std::fs::metadata(path).is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0)
1703}
1704
1705#[cfg(windows)]
1706fn is_runnable_file(path: &Path) -> bool {
1707    is_regular_file(path)
1708}
1709
1710fn receipt_matches_identity(
1711    backend: &NpmPackageBackend,
1712    tv: &ToolVersion,
1713    scope: ToolScope,
1714    identity: &InstallIdentity,
1715    receipt: &NpmInstallReceipt,
1716) -> Result<bool> {
1717    let Some(node_version) = exact_node_dependency(identity) else {
1718        return Ok(false);
1719    };
1720    let requested_installer = identity
1721        .material_options
1722        .get("installer")
1723        .map(String::as_str);
1724    let locked_installer = tv
1725        .options
1726        .get(LOCKED_NPM_INSTALLER_OPTION)
1727        .map(String::as_str);
1728    if requested_installer.is_some()
1729        && locked_installer.is_some()
1730        && requested_installer != locked_installer
1731    {
1732        return Ok(false);
1733    }
1734    let expected_installer = locked_installer.or(requested_installer).unwrap_or("aube");
1735    if receipt.provider != PROVIDER
1736        || receipt.package != backend.package
1737        || receipt.node_version != node_version
1738        || receipt.installer != expected_installer
1739        || receipt.build_policy != NpmPackageBackend::build_policy(tv)?.identity()
1740    {
1741        return Ok(false);
1742    }
1743
1744    let native = [
1745        tv.options.get(LOCKED_NPM_NATIVE_LOCK_KIND_OPTION),
1746        tv.options.get(LOCKED_NPM_NATIVE_LOCK_FORMAT_OPTION),
1747        tv.options.get(LOCKED_NPM_NATIVE_LOCK_SHA256_OPTION),
1748    ];
1749    if native.iter().any(|value| value.is_some()) {
1750        if native.iter().any(|value| value.is_none()) {
1751            return Ok(false);
1752        }
1753        if scope != ToolScope::Global
1754            || receipt.native_lock_format.as_ref() != native[1]
1755            || receipt.native_lock_sha256.as_ref() != native[2]
1756        {
1757            return Ok(false);
1758        }
1759    }
1760    Ok(true)
1761}
1762
1763fn manifest_bins_are_confined(install_root: &Path, manifest: &DynamicToolManifest) -> Result<bool> {
1764    if manifest.bins.is_empty() {
1765        return Ok(false);
1766    }
1767    let canonical_root =
1768        dunce::canonicalize(install_root).map_err(|error| Error::io(install_root, error))?;
1769    for bin in &manifest.bins {
1770        let path = install_root.join(&bin.path);
1771        let Ok(canonical) = dunce::canonicalize(&path) else {
1772            return Ok(false);
1773        };
1774        if !canonical.is_file() || !canonical.starts_with(&canonical_root) {
1775            return Ok(false);
1776        }
1777    }
1778    Ok(true)
1779}
1780
1781fn validate_isolated_install_evidence(
1782    install_root: &Path,
1783    package: &str,
1784    version: &str,
1785    build_policy: &BuildPolicy,
1786    receipt: &NpmInstallReceipt,
1787    locked_graph: Option<&LockedNpmGraph<'_>>,
1788) -> Result<bool> {
1789    if receipt.installer != "aube"
1790        || receipt.native_lock_format.is_some()
1791        || receipt.native_lock_sha256.is_some()
1792    {
1793        return Ok(false);
1794    }
1795    let project_dir = install_root.join(PROJECT_DIR);
1796    if validate_project_manifest(&project_dir, package, version, build_policy).is_err()
1797        || NpmPackageBackend::validate_install_layout(&project_dir, package).is_err()
1798    {
1799        return Ok(false);
1800    }
1801    let graph = match npm_graph_identity(&project_dir, package, version) {
1802        Ok(graph) => graph,
1803        Err(_) => return Ok(false),
1804    };
1805    if receipt.graph_sha256.as_deref() != Some(graph.sha256.as_str())
1806        || receipt.root_integrity.as_deref() != Some(graph.root_integrity.as_str())
1807        || receipt.root_source.as_deref() != Some(graph.root_source.as_str())
1808    {
1809        return Ok(false);
1810    }
1811    if let Some(locked_graph) = locked_graph {
1812        let expected = pipeline::verify::hash_bytes(
1813            locked_graph.lockfile.as_bytes(),
1814            pipeline::HashAlgo::Sha256,
1815        );
1816        if graph.sha256 != expected {
1817            return Ok(false);
1818        }
1819    }
1820    Ok(true)
1821}
1822
1823fn validate_global_install_evidence(
1824    install_root: &Path,
1825    package: &str,
1826    version: &str,
1827    receipt: &NpmInstallReceipt,
1828) -> Result<bool> {
1829    if receipt.graph_sha256.is_some()
1830        || receipt.root_integrity.is_some()
1831        || receipt.root_source.is_some()
1832    {
1833        return Ok(false);
1834    }
1835    let installer = match receipt.installer.parse::<NpmInstaller>() {
1836        Ok(NpmInstaller::Auto) | Err(_) => return Ok(false),
1837        Ok(installer) => installer,
1838    };
1839    let package_manifest = match global_package_manifest_path(install_root, package, installer) {
1840        Some(path) => path,
1841        None => return Ok(false),
1842    };
1843    if !package_manifest_matches(&package_manifest, package, version)? {
1844        return Ok(false);
1845    }
1846
1847    let native_lock = global_native_lock_path(install_root, installer);
1848    match (
1849        receipt.native_lock_format.as_deref(),
1850        receipt.native_lock_sha256.as_deref(),
1851        native_lock,
1852    ) {
1853        (None, None, None) => Ok(installer == NpmInstaller::Npm),
1854        (Some(format), Some(digest), Some(path)) => {
1855            let actual =
1856                crate::inventory::read_stable_regular_file(&path, NPM_NATIVE_LOCK_MAX_BYTES)
1857                    .map(|bytes| pipeline::verify::hash_bytes(&bytes, pipeline::HashAlgo::Sha256));
1858            Ok(native_lock_format_matches(installer, format, &path)
1859                && actual.is_ok_and(|actual| actual == digest))
1860        }
1861        _ => Ok(false),
1862    }
1863}
1864
1865fn global_package_manifest_path(
1866    install_root: &Path,
1867    package: &str,
1868    installer: NpmInstaller,
1869) -> Option<PathBuf> {
1870    match installer {
1871        NpmInstaller::Aube => {
1872            Some(package_install_dir(&install_root.join(PROJECT_DIR), package).join("package.json"))
1873        }
1874        NpmInstaller::Npm => {
1875            #[cfg(windows)]
1876            let modules = install_root.join("node_modules");
1877            #[cfg(not(windows))]
1878            let modules = install_root.join("lib/node_modules");
1879            Some(modules.join(package).join("package.json"))
1880        }
1881        NpmInstaller::Pnpm => find_unique_descendant(
1882            &install_root.join("pnpm-global"),
1883            &format!("/node_modules/{package}/package.json"),
1884        ),
1885        NpmInstaller::Auto => None,
1886    }
1887}
1888
1889fn package_manifest_matches(path: &Path, package: &str, version: &str) -> Result<bool> {
1890    let manifest: serde_json::Value =
1891        match crate::inventory::read_stable_regular_file(path, NPM_PACKAGE_MANIFEST_MAX_BYTES)
1892            .map_err(|error| Error::io(path, error))
1893            .and_then(|bytes| serde_json::from_slice(&bytes).map_err(Error::from))
1894        {
1895            Ok(manifest) => manifest,
1896            Err(_) => return Ok(false),
1897        };
1898    Ok(
1899        manifest.get("name").and_then(serde_json::Value::as_str) == Some(package)
1900            && manifest.get("version").and_then(serde_json::Value::as_str) == Some(version),
1901    )
1902}
1903
1904fn global_native_lock_path(install_root: &Path, installer: NpmInstaller) -> Option<PathBuf> {
1905    match installer {
1906        NpmInstaller::Aube => {
1907            let path = install_root.join(PROJECT_DIR).join(AUBE_LOCKFILE_NAME);
1908            path.is_file().then_some(path)
1909        }
1910        NpmInstaller::Pnpm => {
1911            find_unique_descendant(&install_root.join("pnpm-global"), "pnpm-lock.yaml")
1912        }
1913        NpmInstaller::Npm | NpmInstaller::Auto => None,
1914    }
1915}
1916
1917fn find_unique_descendant(root: &Path, suffix: &str) -> Option<PathBuf> {
1918    if !root.exists() {
1919        return None;
1920    }
1921    let suffix = suffix.replace('\\', "/");
1922    let mut matches = walkdir::WalkDir::new(root)
1923        .follow_links(false)
1924        .max_depth(8)
1925        .into_iter()
1926        .filter_map(|entry| entry.ok())
1927        .filter(|entry| entry.file_type().is_file())
1928        .map(|entry| entry.into_path())
1929        .filter(|path| {
1930            let portable = path.to_string_lossy().replace('\\', "/");
1931            portable == suffix.trim_start_matches('/') || portable.ends_with(&suffix)
1932        });
1933    let first = matches.next()?;
1934    matches.next().is_none().then_some(first)
1935}
1936
1937fn native_lock_format_matches(installer: NpmInstaller, format: &str, path: &Path) -> bool {
1938    let expected_name = match installer {
1939        NpmInstaller::Aube => "aube-lock.yaml",
1940        NpmInstaller::Pnpm => "pnpm-lock.yaml",
1941        NpmInstaller::Npm | NpmInstaller::Auto => return false,
1942    };
1943    if path.file_name().and_then(std::ffi::OsStr::to_str) != Some(expected_name) {
1944        return false;
1945    }
1946    match installer {
1947        NpmInstaller::Aube => format == AUBE_LOCK_FORMAT,
1948        NpmInstaller::Pnpm => format == "pnpm-v9",
1949        NpmInstaller::Npm | NpmInstaller::Auto => false,
1950    }
1951}
1952
1953#[allow(clippy::too_many_arguments)]
1954fn install_matches(
1955    install_root: &Path,
1956    expected_identity: &InstallIdentity,
1957    expected_package: &str,
1958    locked_graph: Option<&LockedNpmGraph<'_>>,
1959    build_policy: &BuildPolicy,
1960) -> Result<bool> {
1961    if !is_regular_file(&install_root.join(".osdk-complete")) {
1962        return Ok(false);
1963    }
1964    let manifest = match DynamicToolManifest::load(install_root) {
1965        Ok(manifest) => manifest,
1966        Err(_) => return Ok(false),
1967    };
1968    let node_version = exact_node_dependency(expected_identity)
1969        .ok_or_else(|| Error::other("npm install identity is missing its Node dependency"))?;
1970    let receipt = match load_npm_receipt(install_root) {
1971        Ok(receipt) => receipt,
1972        Err(_) => return Ok(false),
1973    };
1974    if !manifest.matches_identity(expected_identity)
1975        || receipt.provider != PROVIDER
1976        || receipt.package != expected_package
1977        || receipt.node_version != node_version
1978        || receipt.build_policy != build_policy.identity()
1979        || !manifest_bins_are_confined(install_root, &manifest)?
1980    {
1981        return Ok(false);
1982    }
1983
1984    let project_dir = install_root.join(PROJECT_DIR);
1985    if validate_project_manifest(
1986        &project_dir,
1987        expected_package,
1988        &expected_identity.version,
1989        build_policy,
1990    )
1991    .is_err()
1992        || NpmPackageBackend::validate_install_layout(&project_dir, expected_package).is_err()
1993    {
1994        return Ok(false);
1995    }
1996    let graph_identity =
1997        match npm_graph_identity(&project_dir, expected_package, &expected_identity.version) {
1998            Ok(identity) => identity,
1999            Err(_) => return Ok(false),
2000        };
2001    if receipt.graph_sha256.as_deref() != Some(graph_identity.sha256.as_str())
2002        || receipt.root_integrity.as_deref() != Some(graph_identity.root_integrity.as_str())
2003        || receipt.root_source.as_deref() != Some(graph_identity.root_source.as_str())
2004    {
2005        return Ok(false);
2006    }
2007    if let Some(graph) = locked_graph {
2008        let expected =
2009            pipeline::verify::hash_bytes(graph.lockfile.as_bytes(), pipeline::HashAlgo::Sha256);
2010        return Ok(graph_identity.sha256 == expected);
2011    }
2012    Ok(true)
2013}
2014
2015fn validate_project_manifest(
2016    project_dir: &Path,
2017    package: &str,
2018    version: &str,
2019    build_policy: &BuildPolicy,
2020) -> Result<()> {
2021    let path = project_dir.join("package.json");
2022    let bytes = crate::inventory::read_stable_regular_file(&path, NPM_PACKAGE_MANIFEST_MAX_BYTES)
2023        .map_err(|error| Error::io(&path, error))?;
2024    let manifest: serde_json::Value = serde_json::from_slice(&bytes)?;
2025    if manifest
2026        .get("dependencies")
2027        .and_then(|dependencies| dependencies.get(package))
2028        .and_then(serde_json::Value::as_str)
2029        != Some(version)
2030    {
2031        return Err(Error::other(crate::t!(
2032            "err.npm_project_manifest_identity_mismatch",
2033            package = package,
2034            version = version
2035        )));
2036    }
2037    let expected_allow_builds = match build_policy {
2038        BuildPolicy::Packages(packages) => Some(
2039            packages
2040                .iter()
2041                .map(|package| (package.clone(), serde_json::Value::Bool(true)))
2042                .collect::<serde_json::Map<_, _>>(),
2043        ),
2044        BuildPolicy::Deny | BuildPolicy::AllowAll => None,
2045    };
2046    let actual_allow_builds = manifest
2047        .get("aube")
2048        .and_then(|aube| aube.get("allowBuilds"))
2049        .and_then(serde_json::Value::as_object);
2050    if actual_allow_builds != expected_allow_builds.as_ref() {
2051        return Err(Error::other(crate::t!(
2052            "err.npm_project_manifest_build_policy_mismatch",
2053            package = package
2054        )));
2055    }
2056    Ok(())
2057}
2058
2059fn npm_graph_identity(
2060    project_dir: &Path,
2061    package: &str,
2062    version: &str,
2063) -> Result<NpmGraphIdentity> {
2064    let path = project_dir.join(AUBE_LOCKFILE_NAME);
2065    let bytes = crate::inventory::read_stable_regular_file(&path, NPM_NATIVE_LOCK_MAX_BYTES)
2066        .map_err(|error| Error::io(&path, error))?;
2067    let lockfile: IdentityLockfile = serde_yaml::from_slice(&bytes).map_err(|error| {
2068        Error::other(crate::t!(
2069            "err.npm_graph_parse_invalid",
2070            path = path.display(),
2071            error = error
2072        ))
2073    })?;
2074    let dependency = lockfile
2075        .importers
2076        .get(".")
2077        .and_then(|importer| importer.dependencies.get(package))
2078        .ok_or_else(|| Error::other(crate::t!("err.npm_graph_root_missing", package = package)))?;
2079    if dependency.specifier != version
2080        || !(dependency.version == version
2081            || dependency
2082                .version
2083                .strip_prefix(version)
2084                .is_some_and(|suffix| suffix.starts_with('(')))
2085    {
2086        return Err(Error::other(crate::t!(
2087            "err.npm_graph_root_version_mismatch",
2088            package = package,
2089            expected = version,
2090            actual = dependency.version
2091        )));
2092    }
2093    // Aube v9 keeps peer context on the importer's resolved value and in
2094    // `snapshots`, while `packages` remains keyed by canonical name/version.
2095    let package_key = format!("{package}@{version}");
2096    let root = lockfile.packages.get(&package_key).ok_or_else(|| {
2097        Error::other(crate::t!(
2098            "err.npm_graph_resolved_root_missing",
2099            package = package,
2100            version = version
2101        ))
2102    })?;
2103    if root.resolution.integrity.trim().is_empty() {
2104        return Err(Error::other(crate::t!(
2105            "err.npm_graph_root_integrity_missing",
2106            package = package,
2107            version = version
2108        )));
2109    }
2110    let root_tarball = root.resolution.tarball.clone();
2111    Ok(NpmGraphIdentity {
2112        sha256: pipeline::verify::hash_bytes(&bytes, pipeline::HashAlgo::Sha256),
2113        root_integrity: root.resolution.integrity.clone(),
2114        root_source: root_tarball
2115            .clone()
2116            .unwrap_or_else(|| format!("npm:{package}@{version}")),
2117        root_tarball,
2118    })
2119}
2120
2121fn validate_unlocked_graph_identity(
2122    identity: &NpmGraphIdentity,
2123    expected_checksum: &pipeline::Checksum,
2124    expected_urls: &[String],
2125    package: &str,
2126    version: &str,
2127) -> Result<()> {
2128    let actual_checksum =
2129        pipeline::verify::parse_sri(&identity.root_integrity).ok_or_else(|| {
2130            Error::other(crate::t!(
2131                "err.npm_graph_root_integrity_invalid",
2132                package = package,
2133                version = version
2134            ))
2135        })?;
2136    if &actual_checksum != expected_checksum {
2137        return Err(Error::ChecksumMismatch {
2138            name: format!("npm root package {package}@{version}"),
2139            expected: format_checksum(expected_checksum),
2140            actual: format_checksum(&actual_checksum),
2141        });
2142    }
2143    if let Some(tarball) = identity.root_tarball.as_ref() {
2144        if !expected_urls.iter().any(|url| url == tarball) {
2145            return Err(Error::other(crate::t!(
2146                "err.npm_graph_root_source_mismatch",
2147                package = package,
2148                version = version
2149            )));
2150        }
2151    }
2152    Ok(())
2153}
2154
2155fn format_checksum(checksum: &pipeline::Checksum) -> String {
2156    let algorithm = match checksum.algo {
2157        pipeline::HashAlgo::Sha256 => "sha256",
2158        pipeline::HashAlgo::Sha512 => "sha512",
2159        pipeline::HashAlgo::Blake3 => "blake3",
2160    };
2161    format!("{algorithm}:{}", checksum.hex)
2162}
2163
2164#[cfg(windows)]
2165fn node_executable_name() -> &'static str {
2166    "node.exe"
2167}
2168#[cfg(not(windows))]
2169fn node_executable_name() -> &'static str {
2170    "node"
2171}
2172
2173fn valid_npm_segment(value: &str) -> bool {
2174    !value.is_empty()
2175        && value.len() <= 214
2176        && value != "."
2177        && value != ".."
2178        && !is_windows_reserved_component(value)
2179        && value.chars().all(|character| {
2180            character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
2181        })
2182}
2183
2184fn is_windows_reserved_component(value: &str) -> bool {
2185    let trimmed = value.trim_end_matches([' ', '.']);
2186    if trimmed.is_empty() {
2187        return true;
2188    }
2189    matches!(
2190        trimmed.to_ascii_uppercase().as_str(),
2191        "CON"
2192            | "PRN"
2193            | "AUX"
2194            | "NUL"
2195            | "COM1"
2196            | "COM2"
2197            | "COM3"
2198            | "COM4"
2199            | "COM5"
2200            | "COM6"
2201            | "COM7"
2202            | "COM8"
2203            | "COM9"
2204            | "LPT1"
2205            | "LPT2"
2206            | "LPT3"
2207            | "LPT4"
2208            | "LPT5"
2209            | "LPT6"
2210            | "LPT7"
2211            | "LPT8"
2212            | "LPT9"
2213    )
2214}
2215
2216fn package_install_dir(project_dir: &Path, package: &str) -> PathBuf {
2217    let node_modules = project_dir.join("node_modules");
2218    if let Some(rest) = package.strip_prefix('@') {
2219        let (scope, name) = rest.split_once('/').expect("validated scoped package");
2220        return node_modules.join(format!("@{scope}")).join(name);
2221    }
2222    node_modules.join(package)
2223}
2224
2225fn package_bin_entries(
2226    manifest: &serde_json::Value,
2227    package: &str,
2228) -> Result<Vec<(String, PathBuf)>> {
2229    let mut entries = match manifest.get("bin") {
2230        Some(serde_json::Value::String(path)) if !path.is_empty() => vec![(
2231            package.rsplit('/').next().unwrap_or(package).to_string(),
2232            PathBuf::from(path),
2233        )],
2234        Some(serde_json::Value::Object(entries)) => entries
2235            .iter()
2236            .map(|(name, path)| {
2237                let path = path.as_str().filter(|path| !path.is_empty()).ok_or_else(|| {
2238                    Error::other(format!(
2239                        "npm package {package} declares a non-string or empty bin target for `{name}`"
2240                    ))
2241                })?;
2242                Ok((name.clone(), PathBuf::from(path)))
2243            })
2244            .collect::<Result<Vec<_>>>()?,
2245        Some(_) | None => Vec::new(),
2246    };
2247    for (name, _) in &entries {
2248        if !valid_project_bin_name(name) {
2249            return Err(Error::other(format!(
2250                "npm package {package} declares unsafe bin name `{name}`"
2251            )));
2252        }
2253    }
2254    entries.sort_by(|left, right| left.0.cmp(&right.0));
2255    #[cfg(windows)]
2256    {
2257        let mut folded = BTreeSet::new();
2258        if entries
2259            .iter()
2260            .any(|(name, _)| !folded.insert(name.to_ascii_lowercase()))
2261        {
2262            return Err(Error::other(format!(
2263                "npm package {package} declares colliding Windows bin names"
2264            )));
2265        }
2266    }
2267    if entries.is_empty() {
2268        return Err(Error::other(crate::t!(
2269            "err.npm_dynamic_no_validated_executables",
2270            tool = format!("npm:{package}")
2271        )));
2272    }
2273    Ok(entries)
2274}
2275
2276fn valid_project_bin_name(name: &str) -> bool {
2277    !name.is_empty()
2278        && name != "."
2279        && name != ".."
2280        && !name.contains(['/', '\\'])
2281        && !name.chars().any(char::is_control)
2282        && !is_windows_reserved_component(name)
2283}
2284
2285#[cfg(not(windows))]
2286fn validate_unix_project_launcher(
2287    launcher: &Path,
2288    name: &str,
2289    package: &str,
2290    declared_target: &Path,
2291) -> Result<()> {
2292    let metadata =
2293        std::fs::symlink_metadata(launcher).map_err(|error| Error::io(launcher, error))?;
2294    if metadata.file_type().is_symlink() {
2295        let actual = dunce::canonicalize(launcher).map_err(|error| Error::io(launcher, error))?;
2296        if actual == declared_target {
2297            return Ok(());
2298        }
2299        return Err(Error::other(format!(
2300            "project launcher `{name}` does not point at the bin declared by {package}"
2301        )));
2302    }
2303    if !metadata.is_file() || metadata.len() > PROJECT_NPM_LAUNCHER_MAX_BYTES {
2304        return Err(Error::other(format!(
2305            "project launcher `{name}` for {package} is not a small regular wrapper"
2306        )));
2307    }
2308    let text = std::fs::read_to_string(launcher).map_err(|error| Error::io(launcher, error))?;
2309    if let Some(relative) = text
2310        .lines()
2311        .find_map(|line| line.strip_prefix("# aube-bin-shim v2 target="))
2312    {
2313        let relative = PathBuf::from(relative);
2314        if relative.is_absolute()
2315            || relative.as_os_str().is_empty()
2316            || relative.components().any(|component| {
2317                matches!(
2318                    component,
2319                    std::path::Component::RootDir | std::path::Component::Prefix(_)
2320                )
2321            })
2322        {
2323            return Err(Error::other(format!(
2324                "project launcher `{name}` for {package} has an unsafe Aube target"
2325            )));
2326        }
2327        let target = launcher.parent().unwrap_or(Path::new("")).join(relative);
2328        let actual = dunce::canonicalize(&target).map_err(|error| Error::io(&target, error))?;
2329        return (actual == declared_target).then_some(()).ok_or_else(|| {
2330            Error::other(format!(
2331                "project launcher `{name}` does not execute the bin declared by {package}"
2332            ))
2333        });
2334    }
2335    let target = parse_unix_exec_wrapper(&text, launcher.parent().unwrap_or(Path::new("")))
2336        .ok_or_else(|| {
2337            Error::other(format!(
2338                "project launcher `{name}` for {package} cannot be resolved safely"
2339            ))
2340        })?;
2341    let actual = dunce::canonicalize(&target).map_err(|error| Error::io(&target, error))?;
2342    if actual == declared_target {
2343        Ok(())
2344    } else {
2345        Err(Error::other(format!(
2346            "project launcher `{name}` does not execute the bin declared by {package}"
2347        )))
2348    }
2349}
2350
2351#[cfg(not(windows))]
2352fn parse_unix_exec_wrapper(text: &str, wrapper_dir: &Path) -> Option<PathBuf> {
2353    let mut command = None;
2354    for raw_line in text.lines() {
2355        let line = raw_line.trim();
2356        if line.is_empty() || line.starts_with("#!") || line.starts_with('#') {
2357            continue;
2358        }
2359        if line.contains([';', '&', '|', '>', '<', '`']) || line.contains("$(") {
2360            return None;
2361        }
2362        let words = shell_words(line)?;
2363        if words.len() != 4
2364            || words[0] != "exec"
2365            || words[1] != "node"
2366            || !matches!(words[3].as_str(), "$@" | "${@}")
2367            || command.replace(words[2].clone()).is_some()
2368        {
2369            return None;
2370        }
2371    }
2372    let raw = command?;
2373    let expanded = raw
2374        .strip_prefix("$basedir/")
2375        .or_else(|| raw.strip_prefix("${basedir}/"))
2376        .map(|suffix| wrapper_dir.join(suffix))
2377        .unwrap_or_else(|| PathBuf::from(raw));
2378    Some(expanded)
2379}
2380
2381#[cfg(not(windows))]
2382fn shell_words(line: &str) -> Option<Vec<String>> {
2383    let mut words = Vec::new();
2384    let mut current = String::new();
2385    let mut quote = None;
2386    let mut chars = line.chars().peekable();
2387    while let Some(character) = chars.next() {
2388        match (quote, character) {
2389            (Some(expected), actual) if actual == expected => quote = None,
2390            (None, '\'' | '"') => quote = Some(character),
2391            (None, actual) if actual.is_whitespace() => {
2392                if !current.is_empty() {
2393                    words.push(std::mem::take(&mut current));
2394                }
2395            }
2396            (Some('\''), actual) => current.push(actual),
2397            (_, '\\') => current.push(chars.next()?),
2398            (_, actual) => current.push(actual),
2399        }
2400    }
2401    if quote.is_some() {
2402        return None;
2403    }
2404    if !current.is_empty() {
2405        words.push(current);
2406    }
2407    Some(words)
2408}
2409
2410#[cfg(windows)]
2411fn validate_windows_project_launcher(
2412    bin_dir: &Path,
2413    launcher: &Path,
2414    name: &str,
2415    package: &str,
2416    declared_target: &Path,
2417) -> Result<()> {
2418    let extension = launcher
2419        .extension()
2420        .and_then(|extension| extension.to_str())
2421        .map(str::to_ascii_lowercase);
2422    if !matches!(extension.as_deref(), Some("cmd") | Some("bat")) {
2423        return Err(Error::other(format!(
2424            "project launcher `{name}` for {package} is an opaque Windows executable"
2425        )));
2426    }
2427    for shadow in [bin_dir.join(format!("{name}.exe")), bin_dir.join(name)] {
2428        if shadow.exists() {
2429            return Err(Error::other(format!(
2430                "project launcher `{name}` for {package} has an opaque Windows shadow"
2431            )));
2432        }
2433    }
2434    let target = parse_windows_project_wrapper(launcher)?;
2435    let actual = dunce::canonicalize(&target).map_err(|error| Error::io(&target, error))?;
2436    if actual != declared_target {
2437        return Err(Error::other(format!(
2438            "project launcher `{name}` does not execute the bin declared by {package}"
2439        )));
2440    }
2441    Ok(())
2442}
2443
2444#[cfg(windows)]
2445fn parse_windows_project_wrapper(path: &Path) -> Result<PathBuf> {
2446    let metadata = std::fs::metadata(path).map_err(|error| Error::io(path, error))?;
2447    if !metadata.is_file() || metadata.len() > PROJECT_NPM_LAUNCHER_MAX_BYTES {
2448        return Err(Error::other(format!(
2449            "project npm wrapper is not a small regular file: {}",
2450            path.display()
2451        )));
2452    }
2453    let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
2454    if text.contains(OSDK_PROJECT_NPM_CMD_MARKER) {
2455        let relative = parse_osdk_project_cmd_wrapper(&text).ok_or_else(|| {
2456            Error::other(format!(
2457                "invalid osdk project npm wrapper template: {}",
2458                path.display()
2459            ))
2460        })?;
2461        return path
2462            .parent()
2463            .map(|parent| parent.join(relative.replace('\\', "/")))
2464            .ok_or_else(|| {
2465                Error::other(format!(
2466                    "project npm wrapper does not execute a declared target: {}",
2467                    path.display()
2468                ))
2469            });
2470    }
2471    let lower = text.to_ascii_lowercase();
2472    if text.contains('\0') || lower.contains("powershell") || lower.contains("cmd /") {
2473        return Err(Error::other(format!(
2474            "project npm wrapper contains an unsupported command: {}",
2475            path.display()
2476        )));
2477    }
2478    let recognized = is_aube_windows_wrapper(&text) || is_npm_windows_wrapper(&text);
2479    if !recognized {
2480        return Err(Error::other(format!(
2481            "project npm wrapper does not match a recognized safe template: {}",
2482            path.display()
2483        )));
2484    }
2485    let normalized = text.replace("%dp0%", "%~dp0");
2486    let marker = "\"%~dp0\\";
2487    let mut targets = normalized
2488        .match_indices(marker)
2489        .filter_map(|(start, _)| {
2490            let suffix = &normalized[start + marker.len()..];
2491            let end = suffix.find('\"')?;
2492            let relative = &suffix[..end];
2493            let rest = suffix[end + 1..].trim_start();
2494            rest.starts_with("%*").then(|| relative.to_string())
2495        })
2496        .collect::<BTreeSet<_>>();
2497    if targets.len() != 1 {
2498        return Err(Error::other(format!(
2499            "project npm wrapper cannot be resolved unambiguously: {}",
2500            path.display()
2501        )));
2502    }
2503    let relative = PathBuf::from(
2504        targets
2505            .pop_first()
2506            .expect("validated one target")
2507            .replace('\\', "/"),
2508    );
2509    if relative.is_absolute()
2510        || relative.components().any(|component| {
2511            matches!(
2512                component,
2513                std::path::Component::RootDir | std::path::Component::Prefix(_)
2514            )
2515        })
2516    {
2517        return Err(Error::other(format!(
2518            "project npm wrapper has an unsafe target: {}",
2519            path.display()
2520        )));
2521    }
2522    path.parent()
2523        .map(|parent| parent.join(relative))
2524        .ok_or_else(|| {
2525            Error::other(format!(
2526                "project npm wrapper does not execute a declared target: {}",
2527                path.display()
2528            ))
2529        })
2530}
2531
2532#[cfg_attr(not(any(windows, test)), allow(dead_code))]
2533fn render_osdk_project_cmd_wrapper(relative_target: &str) -> Result<String> {
2534    if !valid_osdk_project_cmd_target(relative_target) {
2535        return Err(Error::other(
2536            "project npm bin path cannot be represented safely in cmd.exe",
2537        ));
2538    }
2539    Ok(format!(
2540        "@echo off\r\n{OSDK_PROJECT_NPM_CMD_MARKER}\r\nnode \"%~dp0{relative_target}\" %*\r\n"
2541    ))
2542}
2543
2544#[cfg_attr(not(any(windows, test)), allow(dead_code))]
2545fn parse_osdk_project_cmd_wrapper(text: &str) -> Option<&str> {
2546    let prefix = format!("@echo off\r\n{OSDK_PROJECT_NPM_CMD_MARKER}\r\nnode \"%~dp0");
2547    let relative_target = text.strip_prefix(&prefix)?.strip_suffix("\" %*\r\n")?;
2548    valid_osdk_project_cmd_target(relative_target).then_some(relative_target)
2549}
2550
2551#[cfg_attr(not(any(windows, test)), allow(dead_code))]
2552fn valid_osdk_project_cmd_target(relative_target: &str) -> bool {
2553    !relative_target.is_empty()
2554        && !relative_target.starts_with(['/', '\\'])
2555        && relative_target.as_bytes().get(1) != Some(&b':')
2556        && !relative_target.contains('/')
2557        && !relative_target.chars().any(|character| {
2558            character.is_control()
2559                || matches!(character, '%' | '!' | '"' | '&' | '|' | '<' | '>' | '^')
2560        })
2561}
2562
2563#[cfg(windows)]
2564fn is_aube_windows_wrapper(text: &str) -> bool {
2565    let mut lines = text.lines().map(|line| line.trim_end_matches('\r'));
2566    if lines.next() != Some("@SETLOCAL") {
2567        return false;
2568    }
2569    let mut line = lines.next();
2570    if line.is_some_and(|line| line.starts_with("@SET NODE_PATH=")) {
2571        line = lines.next();
2572    }
2573    let Some(if_line) = line else {
2574        return false;
2575    };
2576    if if_line.starts_with("@\"%~dp0\\") && if_line.ends_with("\" %*") {
2577        return lines.next().is_none();
2578    }
2579    if !if_line.starts_with("@IF EXIST \"%~dp0\\") || !if_line.ends_with(".exe\" (") {
2580        return false;
2581    }
2582    let Some(local) = lines.next() else {
2583        return false;
2584    };
2585    if !local.starts_with("  \"%~dp0\\") || !local.ends_with("\" %*") {
2586        return false;
2587    }
2588    if lines.next() != Some(") ELSE (") || lines.next() != Some("  @SET PATHEXT=%PATHEXT:;.JS;=;%")
2589    {
2590        return false;
2591    }
2592    let Some(fallback) = lines.next() else {
2593        return false;
2594    };
2595    fallback.starts_with("  ")
2596        && fallback.contains(" \"%~dp0\\")
2597        && fallback.ends_with("\" %*")
2598        && lines.next() == Some(")")
2599        && lines.next().is_none()
2600}
2601
2602#[cfg(windows)]
2603fn is_npm_windows_wrapper(text: &str) -> bool {
2604    let mut lines = text.lines().map(|line| line.trim_end_matches('\r'));
2605    for expected in [
2606        "@ECHO off",
2607        "GOTO start",
2608        ":find_dp0",
2609        "SET dp0=%~dp0",
2610        "EXIT /b",
2611        ":start",
2612        "SETLOCAL",
2613        "CALL :find_dp0",
2614    ] {
2615        if lines.next() != Some(expected) {
2616            return false;
2617        }
2618    }
2619    let remaining = lines.collect::<Vec<_>>().join("\n");
2620    if remaining.contains("\nGOTO ")
2621        || remaining.contains("\nCALL ")
2622        || remaining.contains("\nSTART ")
2623    {
2624        return false;
2625    }
2626    if remaining.contains("IF EXIST \"%dp0%\\") {
2627        remaining.contains("SET \"_prog=")
2628            && remaining.contains("SET PATHEXT=%PATHEXT:;.JS;=;%")
2629            && remaining
2630                .contains("endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & \"%_prog%\"")
2631    } else {
2632        remaining
2633            .lines()
2634            .filter(|line| !line.trim().is_empty())
2635            .count()
2636            == 1
2637            && remaining.contains("\"%dp0%\\")
2638            && remaining.trim_end().ends_with("\" %*")
2639    }
2640}
2641
2642fn discover_bins(install_root: &Path, bin_dir: &Path) -> Result<Vec<DynamicToolBin>> {
2643    let canonical_root =
2644        std::fs::canonicalize(install_root).map_err(|error| Error::io(install_root, error))?;
2645    let mut bins = Vec::new();
2646    for name in discover_bin_names(bin_dir)? {
2647        let absolute = resolve_bin_target(bin_dir, &name)?;
2648        let relative = absolute
2649            .strip_prefix(&canonical_root)
2650            .map_err(|_| {
2651                Error::other(crate::t!(
2652                    "err.npm_bin_outside_install_root",
2653                    name = name,
2654                    path = install_root.display()
2655                ))
2656            })?
2657            .to_path_buf();
2658        bins.push(DynamicToolBin {
2659            name,
2660            path: relative.to_string_lossy().replace('\\', "/"),
2661        });
2662    }
2663    if bins.is_empty() {
2664        return Err(Error::other(crate::t!(
2665            "err.npm_bins_not_discovered",
2666            path = bin_dir.display()
2667        )));
2668    }
2669    Ok(bins)
2670}
2671
2672fn discover_global_bins(install_root: &Path, bin_dir: &Path) -> Result<Vec<DynamicToolBin>> {
2673    let canonical_root =
2674        std::fs::canonicalize(install_root).map_err(|error| Error::io(install_root, error))?;
2675    let canonical_bin_dir =
2676        std::fs::canonicalize(bin_dir).map_err(|error| Error::io(bin_dir, error))?;
2677    let relative_bin_dir = canonical_bin_dir
2678        .strip_prefix(&canonical_root)
2679        .map_err(|_| {
2680            Error::other(crate::t!(
2681                "err.npm_bin_outside_install_root",
2682                name = bin_dir.display(),
2683                path = install_root.display()
2684            ))
2685        })?;
2686    let mut bins = Vec::new();
2687    for name in discover_bin_names(bin_dir)? {
2688        let absolute = resolve_bin_target(bin_dir, &name)?;
2689        absolute.strip_prefix(&canonical_root).map_err(|_| {
2690            Error::other(crate::t!(
2691                "err.npm_bin_outside_install_root",
2692                name = name,
2693                path = install_root.display()
2694            ))
2695        })?;
2696        let entry = global_bin_entry(bin_dir, &name).ok_or_else(|| {
2697            Error::other(crate::t!(
2698                "err.npm_bin_target_unresolved",
2699                name = name,
2700                path = bin_dir.display()
2701            ))
2702        })?;
2703        let file_name = entry.file_name().ok_or_else(|| {
2704            Error::other(crate::t!(
2705                "err.npm_bin_target_unresolved",
2706                name = name,
2707                path = bin_dir.display()
2708            ))
2709        })?;
2710        let relative = relative_bin_dir.join(file_name);
2711        bins.push(DynamicToolBin {
2712            name,
2713            path: relative.to_string_lossy().replace('\\', "/"),
2714        });
2715    }
2716    if bins.is_empty() {
2717        return Err(Error::other(crate::t!(
2718            "err.npm_bins_not_discovered",
2719            path = bin_dir.display()
2720        )));
2721    }
2722    Ok(bins)
2723}
2724
2725fn global_bin_entry(bin_dir: &Path, name: &str) -> Option<PathBuf> {
2726    #[cfg(windows)]
2727    let candidates = [
2728        bin_dir.join(format!("{name}.cmd")),
2729        bin_dir.join(format!("{name}.exe")),
2730        bin_dir.join(format!("{name}.bat")),
2731        bin_dir.join(name),
2732    ];
2733    #[cfg(not(windows))]
2734    let candidates = [bin_dir.join(name)];
2735    candidates.into_iter().find(|candidate| candidate.exists())
2736}
2737
2738fn discover_bin_names(bin_dir: &Path) -> Result<Vec<String>> {
2739    let read_dir = std::fs::read_dir(bin_dir).map_err(|error| Error::io(bin_dir, error))?;
2740    let mut names = Vec::new();
2741    for entry in read_dir {
2742        let entry = entry.map_err(|error| Error::io(bin_dir, error))?;
2743        let path = entry.path();
2744        let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
2745            continue;
2746        };
2747        if file_name.starts_with('.') {
2748            continue;
2749        }
2750        #[cfg(windows)]
2751        let name = {
2752            let lower = file_name.to_ascii_lowercase();
2753            let Some(stripped) = lower
2754                .strip_suffix(".cmd")
2755                .or_else(|| lower.strip_suffix(".exe"))
2756                .or_else(|| lower.strip_suffix(".bat"))
2757            else {
2758                continue;
2759            };
2760            stripped.to_string()
2761        };
2762        #[cfg(not(windows))]
2763        let name = file_name.to_string();
2764        names.push(name);
2765    }
2766    names.sort();
2767    names.dedup();
2768    Ok(names)
2769}
2770
2771fn resolve_bin_target(bin_dir: &Path, name: &str) -> Result<PathBuf> {
2772    #[cfg(windows)]
2773    let candidates = [
2774        bin_dir.join(format!("{name}.cmd")),
2775        bin_dir.join(format!("{name}.exe")),
2776        bin_dir.join(format!("{name}.bat")),
2777    ];
2778    #[cfg(not(windows))]
2779    let candidates = [bin_dir.join(name)];
2780
2781    for candidate in candidates {
2782        if candidate.exists() {
2783            let target =
2784                std::fs::canonicalize(&candidate).map_err(|error| Error::io(&candidate, error))?;
2785            if target.is_file() {
2786                return Ok(target);
2787            }
2788        }
2789    }
2790    Err(Error::other(crate::t!(
2791        "err.npm_bin_target_unresolved",
2792        name = name,
2793        path = bin_dir.display()
2794    )))
2795}
2796
2797/// Path to the atomically replaced pointer for the active curated project npm
2798/// bin generation. Callers that need a rollback snapshot should read this
2799/// file before publishing a new generation.
2800pub fn project_bin_current_path(project_root: &Path) -> PathBuf {
2801    project_root
2802        .join(PROJECT_NPM_BIN_ROOT)
2803        .join(PROJECT_NPM_BIN_CURRENT)
2804}
2805
2806/// Publish an immutable, osdk-owned bin generation for a project npm tool.
2807/// Existing selections are retained only while their exact configured specs
2808/// still match the trusted configuration supplied by the caller.
2809pub fn publish_project_bin_generation(
2810    project_root: &Path,
2811    selection: &ProjectNpmBinSelection,
2812    configured_specs: &BTreeMap<String, String>,
2813) -> Result<PathBuf> {
2814    validate_project_npm_selection_for_project(selection, configured_specs, project_root)?;
2815
2816    let canonical_project =
2817        dunce::canonicalize(project_root).map_err(|error| Error::io(project_root, error))?;
2818    let root = prepare_project_bin_root(project_root, &canonical_project)?;
2819    let _lock = crate::lock::FileLock::acquire(root.join("publish.lock"))?;
2820    let existing_manifest = read_current_project_bin_manifest(project_root)?;
2821    let mut selections = match existing_manifest {
2822        Some((_, manifest)) => manifest
2823            .selections
2824            .into_iter()
2825            .filter(|existing| {
2826                existing.backend != selection.backend
2827                    && configured_specs.get(&existing.backend) == Some(&existing.configured_spec)
2828            })
2829            .collect::<Vec<_>>(),
2830        None => Vec::new(),
2831    };
2832    selections.push(selection.clone());
2833    selections.sort_by(|left, right| left.backend.cmp(&right.backend));
2834    selections.dedup_by(|left, right| left.backend == right.backend);
2835    let mut bins = Vec::new();
2836    let mut names = BTreeSet::new();
2837    for selected in &selections {
2838        validate_project_npm_selection_for_project(selected, configured_specs, project_root)?;
2839        let backend = NpmPackageBackend::from_id(&selected.backend).ok_or_else(|| {
2840            Error::other(format!(
2841                "invalid npm backend in project bin selection: {}",
2842                selected.backend
2843            ))
2844        })?;
2845        for bin in backend.validated_project_package_bins(project_root, &selected.version)? {
2846            let conflict_key = project_bin_conflict_key(&bin.name);
2847            if !names.insert(conflict_key) {
2848                return Err(Error::other(format!(
2849                    "project npm bin `{}` is declared by more than one configured package",
2850                    bin.name
2851                )));
2852            }
2853            bins.push(ProjectNpmBinManifestEntry {
2854                name: bin.name,
2855                backend: selected.backend.clone(),
2856                target: portable_relative_path(&bin.project_relative_target)?,
2857            });
2858        }
2859    }
2860    bins.sort_by(|left, right| left.name.cmp(&right.name));
2861
2862    let generation = project_bin_generation_id(&selections, &bins)?;
2863    let manifest = ProjectNpmBinManifest {
2864        schema: PROJECT_NPM_BIN_SCHEMA,
2865        generation: generation.clone(),
2866        platform: project_bin_platform().into(),
2867        selections,
2868        bins,
2869    };
2870
2871    let generations = root.join(PROJECT_NPM_BIN_GENERATIONS);
2872    ensure_project_bin_directory(&generations, &canonical_project, true)?;
2873    let generation_dir = generations.join(&generation);
2874    if generation_dir.exists() {
2875        validate_project_bin_generation(&canonical_project, &generation_dir, &manifest)?;
2876    } else {
2877        let staging = unique_project_bin_path(&generations, "stage");
2878        std::fs::create_dir(&staging).map_err(|error| Error::io(&staging, error))?;
2879        let build_result = (|| {
2880            let bin_dir = staging.join(PROJECT_NPM_BIN_BIN_DIR);
2881            std::fs::create_dir(&bin_dir).map_err(|error| Error::io(&bin_dir, error))?;
2882            for entry in &manifest.bins {
2883                let target = canonical_project.join(path_from_portable(&entry.target)?);
2884                write_curated_project_launcher(&bin_dir, &entry.name, &target)?;
2885            }
2886            write_project_bin_json(&staging.join(PROJECT_NPM_BIN_MANIFEST), &manifest)
2887        })();
2888        if let Err(error) = build_result {
2889            let _ = std::fs::remove_dir_all(&staging);
2890            return Err(error);
2891        }
2892        if let Err(error) = std::fs::rename(&staging, &generation_dir) {
2893            if !generation_dir.exists() {
2894                let _ = std::fs::remove_dir_all(&staging);
2895                return Err(Error::io(&generation_dir, error));
2896            }
2897            let _ = std::fs::remove_dir_all(&staging);
2898            validate_project_bin_generation(&canonical_project, &generation_dir, &manifest)?;
2899        }
2900    }
2901    validate_project_bin_generation(&canonical_project, &generation_dir, &manifest)?;
2902
2903    let current = ProjectNpmBinCurrent {
2904        schema: PROJECT_NPM_BIN_SCHEMA,
2905        generation,
2906    };
2907    write_project_bin_json(&root.join(PROJECT_NPM_BIN_CURRENT), &current)?;
2908    Ok(generation_dir.join(PROJECT_NPM_BIN_BIN_DIR))
2909}
2910
2911/// Resolve the active curated project bin directory after validating it
2912/// against trusted configuration and the current filesystem. No state is
2913/// changed; a missing current pointer means no curated generation is active.
2914pub fn validated_project_bin_dir(
2915    project_root: &Path,
2916    configured_specs: &BTreeMap<String, String>,
2917) -> Result<Option<PathBuf>> {
2918    let Some((generation_dir, manifest)) = read_current_project_bin_manifest(project_root)? else {
2919        return Ok(None);
2920    };
2921    for selection in &manifest.selections {
2922        validate_project_npm_selection_for_project(selection, configured_specs, project_root)?;
2923    }
2924    if manifest.selections.iter().any(|selection| {
2925        configured_specs.get(&selection.backend) != Some(&selection.configured_spec)
2926    }) {
2927        return Err(Error::other(
2928            "project npm bin manifest does not match trusted project configuration",
2929        ));
2930    }
2931    let canonical_project =
2932        dunce::canonicalize(project_root).map_err(|error| Error::io(project_root, error))?;
2933    let root = ensure_project_bin_directory(
2934        &project_root.join(PROJECT_NPM_BIN_ROOT),
2935        &canonical_project,
2936        false,
2937    )?;
2938    let generations = ensure_project_bin_directory(
2939        &root.join(PROJECT_NPM_BIN_GENERATIONS),
2940        &canonical_project,
2941        false,
2942    )?;
2943    if !generation_dir.starts_with(&generations) {
2944        return Err(Error::other(
2945            "project npm bin generation resolves outside its owned directory",
2946        ));
2947    }
2948    validate_project_bin_generation(&canonical_project, &generation_dir, &manifest)?;
2949    Ok(Some(generation_dir.join(PROJECT_NPM_BIN_BIN_DIR)))
2950}
2951
2952fn validate_project_npm_selection(
2953    selection: &ProjectNpmBinSelection,
2954    configured_specs: &BTreeMap<String, String>,
2955) -> Result<()> {
2956    validate_project_npm_selection_identity(selection, configured_specs)?;
2957    if !matches!(
2958        npm_spec_satisfaction(&selection.configured_spec, &selection.version),
2959        Some(true)
2960    ) {
2961        return Err(Error::other(format!(
2962            "project npm bin selection does not match trusted configuration: {}",
2963            selection.backend
2964        )));
2965    }
2966    Ok(())
2967}
2968
2969fn validate_project_npm_selection_for_project(
2970    selection: &ProjectNpmBinSelection,
2971    configured_specs: &BTreeMap<String, String>,
2972    project_root: &Path,
2973) -> Result<()> {
2974    validate_project_npm_selection_identity(selection, configured_specs)?;
2975    if validate_project_npm_selection(selection, configured_specs).is_ok() {
2976        return Ok(());
2977    }
2978    if !npm_channel_spec(&selection.configured_spec)
2979        || !project_lock_binds_selection(project_root, selection)?
2980    {
2981        return Err(Error::other(format!(
2982            "project npm bin selection does not match trusted configuration: {}",
2983            selection.backend
2984        )));
2985    }
2986    Ok(())
2987}
2988
2989fn validate_project_npm_selection_identity(
2990    selection: &ProjectNpmBinSelection,
2991    configured_specs: &BTreeMap<String, String>,
2992) -> Result<()> {
2993    if NpmPackageBackend::from_id(&selection.backend)
2994        .is_none_or(|backend| backend.id() != selection.backend)
2995        || selection.configured_spec.trim().is_empty()
2996        || selection.version.trim().is_empty()
2997        || configured_specs.get(&selection.backend) != Some(&selection.configured_spec)
2998    {
2999        return Err(Error::other(format!(
3000            "project npm bin selection does not match trusted configuration: {}",
3001            selection.backend
3002        )));
3003    }
3004    Ok(())
3005}
3006
3007fn npm_channel_spec(spec: &str) -> bool {
3008    let spec = spec.trim();
3009    !spec.is_empty()
3010        && spec
3011            .chars()
3012            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
3013        && npm_spec_satisfaction(spec, "0.0.0").is_none()
3014}
3015
3016fn project_lock_binds_selection(
3017    project_root: &Path,
3018    selection: &ProjectNpmBinSelection,
3019) -> Result<bool> {
3020    let path = project_root.join(PROJECT_NPM_LOCKFILE);
3021    let bytes = match crate::inventory::read_stable_regular_file(&path, PROJECT_NPM_LOCK_MAX_BYTES)
3022    {
3023        Ok(bytes) => bytes,
3024        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
3025        Err(error) => return Err(Error::io(&path, error)),
3026    };
3027    let lock: ProjectNpmLockfile = toml::from_str(std::str::from_utf8(&bytes).map_err(|_| {
3028        Error::other(format!("project npm lock is not UTF-8: {}", path.display()))
3029    })?)?;
3030    if lock.schema != 3 {
3031        return Ok(false);
3032    }
3033    let package = NpmPackageBackend::from_id(&selection.backend)
3034        .expect("selection backend was validated before lock lookup")
3035        .package;
3036    Ok(lock.platforms.values().any(|platform| {
3037        platform
3038            .tools
3039            .get(&selection.backend)
3040            .is_some_and(|locked| {
3041                locked.request == selection.configured_spec
3042                    && locked.version == selection.version
3043                    && locked.npm.as_ref().is_some_and(|npm| {
3044                        npm.package == package && npm.scope == ToolScope::Project.as_str()
3045                    })
3046            })
3047    }))
3048}
3049
3050fn npm_spec_satisfaction(configured_spec: &str, exact_version: &str) -> Option<bool> {
3051    let configured_spec = configured_spec.trim();
3052    let exact_version = exact_version
3053        .trim()
3054        .strip_prefix('v')
3055        .unwrap_or(exact_version.trim());
3056    let Ok(version) = semver::Version::parse(exact_version) else {
3057        return Some(false);
3058    };
3059    if let Ok(exact) =
3060        semver::Version::parse(configured_spec.strip_prefix('v').unwrap_or(configured_spec))
3061    {
3062        return Some(exact == version);
3063    }
3064    let numeric_prefix = configured_spec
3065        .strip_prefix('v')
3066        .unwrap_or(configured_spec)
3067        .split('.')
3068        .collect::<Vec<_>>();
3069    if !numeric_prefix.is_empty()
3070        && numeric_prefix.len() < 3
3071        && numeric_prefix
3072            .iter()
3073            .all(|component| !component.is_empty() && component.chars().all(|c| c.is_ascii_digit()))
3074    {
3075        let exact_components = [version.major.to_string(), version.minor.to_string()];
3076        return Some(
3077            numeric_prefix
3078                .iter()
3079                .zip(exact_components.iter())
3080                .all(|(expected, actual)| *expected == actual),
3081        );
3082    }
3083
3084    // Config entries use npm's version vocabulary. A successful semver
3085    // requirement parse covers caret/tilde/comparator ranges and numeric
3086    // prefixes such as `3` and `3.6`. Symbolic dist-tags (`latest`, `beta`,
3087    // custom channels) are deliberately rejected here: their meaning cannot
3088    // be reconstructed from an exact installed version alone.
3089    npm_semver_requirements(configured_spec).map(|requirements| {
3090        requirements
3091            .iter()
3092            .any(|requirement| requirement.matches(&version))
3093    })
3094}
3095
3096fn npm_semver_requirements(spec: &str) -> Option<Vec<semver::VersionReq>> {
3097    spec.split("||")
3098        .map(|alternative| {
3099            let alternative = alternative.trim();
3100            if alternative.is_empty() || !npm_range_shape(alternative) {
3101                return None;
3102            }
3103            let normalized = alternative
3104                .split_whitespace()
3105                .collect::<Vec<_>>()
3106                .join(", ");
3107            semver::VersionReq::parse(&normalized).ok()
3108        })
3109        .collect()
3110}
3111
3112fn npm_range_shape(spec: &str) -> bool {
3113    spec.chars().all(|character| {
3114        character.is_ascii_digit()
3115            || character.is_ascii_whitespace()
3116            || matches!(
3117                character,
3118                '.' | '-' | '+' | '*' | 'x' | 'X' | '^' | '~' | '<' | '>' | '='
3119            )
3120    })
3121}
3122
3123fn read_current_project_bin_manifest(
3124    project_root: &Path,
3125) -> Result<Option<(PathBuf, ProjectNpmBinManifest)>> {
3126    let current_path = project_bin_current_path(project_root);
3127    let current = match read_project_bin_json::<ProjectNpmBinCurrent>(&current_path) {
3128        Ok(current) => current,
3129        Err(Error::Io { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
3130            return Ok(None);
3131        }
3132        Err(error) => return Err(error),
3133    };
3134    if current.schema != PROJECT_NPM_BIN_SCHEMA || !valid_generation_id(&current.generation) {
3135        return Err(Error::other(format!(
3136            "invalid project npm bin pointer at {}",
3137            current_path.display()
3138        )));
3139    }
3140    let generation_dir = project_root
3141        .join(PROJECT_NPM_BIN_ROOT)
3142        .join(PROJECT_NPM_BIN_GENERATIONS)
3143        .join(&current.generation);
3144    let generation_metadata = std::fs::symlink_metadata(&generation_dir)
3145        .map_err(|error| Error::io(&generation_dir, error))?;
3146    if !generation_metadata.is_dir() || generation_metadata.file_type().is_symlink() {
3147        return Err(Error::other(format!(
3148            "project npm bin generation is not an owned directory: {}",
3149            generation_dir.display()
3150        )));
3151    }
3152    let generation_dir =
3153        dunce::canonicalize(&generation_dir).map_err(|error| Error::io(&generation_dir, error))?;
3154    let manifest_path = generation_dir.join(PROJECT_NPM_BIN_MANIFEST);
3155    let manifest = read_project_bin_json::<ProjectNpmBinManifest>(&manifest_path)?;
3156    if manifest.schema != PROJECT_NPM_BIN_SCHEMA
3157        || manifest.generation != current.generation
3158        || manifest.platform != project_bin_platform()
3159    {
3160        return Err(Error::other(format!(
3161            "project npm bin manifest identity mismatch at {}",
3162            manifest_path.display()
3163        )));
3164    }
3165    Ok(Some((generation_dir, manifest)))
3166}
3167
3168fn prepare_project_bin_root(project_root: &Path, canonical_project: &Path) -> Result<PathBuf> {
3169    let osdk = project_root.join(".osdk");
3170    ensure_project_bin_directory(&osdk, canonical_project, true)?;
3171    ensure_project_bin_directory(
3172        &project_root.join(PROJECT_NPM_BIN_ROOT),
3173        canonical_project,
3174        true,
3175    )
3176}
3177
3178fn ensure_project_bin_directory(
3179    path: &Path,
3180    canonical_project: &Path,
3181    create: bool,
3182) -> Result<PathBuf> {
3183    if create {
3184        std::fs::create_dir(path)
3185            .or_else(|error| {
3186                (error.kind() == std::io::ErrorKind::AlreadyExists)
3187                    .then_some(())
3188                    .ok_or(error)
3189            })
3190            .map_err(|error| Error::io(path, error))?;
3191    }
3192    let metadata = std::fs::symlink_metadata(path).map_err(|error| Error::io(path, error))?;
3193    if !metadata.is_dir() || metadata.file_type().is_symlink() {
3194        return Err(Error::other(format!(
3195            "project npm bin path is not an owned directory: {}",
3196            path.display()
3197        )));
3198    }
3199    let canonical = dunce::canonicalize(path).map_err(|error| Error::io(path, error))?;
3200    if !canonical.starts_with(canonical_project) {
3201        return Err(Error::other(format!(
3202            "project npm bin directory escapes project: {}",
3203            path.display()
3204        )));
3205    }
3206    Ok(canonical)
3207}
3208
3209fn validate_project_bin_generation(
3210    canonical_project: &Path,
3211    generation_dir: &Path,
3212    expected_manifest: &ProjectNpmBinManifest,
3213) -> Result<()> {
3214    if expected_manifest.selections.is_empty()
3215        || expected_manifest.bins.is_empty()
3216        || !valid_generation_id(&expected_manifest.generation)
3217        || expected_manifest.schema != PROJECT_NPM_BIN_SCHEMA
3218        || expected_manifest.platform != project_bin_platform()
3219        || generation_dir.file_name().and_then(|name| name.to_str())
3220            != Some(expected_manifest.generation.as_str())
3221        || project_bin_generation_id(&expected_manifest.selections, &expected_manifest.bins)?
3222            != expected_manifest.generation
3223    {
3224        return Err(Error::other("invalid project npm bin generation identity"));
3225    }
3226    let actual_manifest = read_project_bin_json::<ProjectNpmBinManifest>(
3227        &generation_dir.join(PROJECT_NPM_BIN_MANIFEST),
3228    )?;
3229    if &actual_manifest != expected_manifest {
3230        return Err(Error::other(format!(
3231            "project npm bin generation manifest mismatch at {}",
3232            generation_dir.display()
3233        )));
3234    }
3235
3236    let resolved_bins =
3237        declared_project_bin_entries(canonical_project, &expected_manifest.selections)?;
3238    let declared_manifest_entries = resolved_bins
3239        .iter()
3240        .map(|(entry, _)| entry.clone())
3241        .collect::<Vec<_>>();
3242    if declared_manifest_entries != expected_manifest.bins {
3243        return Err(Error::other(
3244            "project npm bin manifest no longer matches installed package declarations",
3245        ));
3246    }
3247
3248    let mut expected_files = BTreeSet::new();
3249    expected_files.insert(PROJECT_NPM_BIN_MANIFEST.to_string());
3250    expected_files.insert(PROJECT_NPM_BIN_BIN_DIR.to_string());
3251    for (entry, target) in resolved_bins {
3252        let launcher_name = curated_launcher_name(&entry.name);
3253        expected_files.insert(format!("{PROJECT_NPM_BIN_BIN_DIR}/{launcher_name}"));
3254        validate_curated_project_launcher(
3255            &generation_dir
3256                .join(PROJECT_NPM_BIN_BIN_DIR)
3257                .join(launcher_name),
3258            &target,
3259        )?;
3260    }
3261
3262    let actual_files = project_bin_file_set(generation_dir)?;
3263    if actual_files != expected_files {
3264        return Err(Error::other(format!(
3265            "project npm bin generation contains unexpected or missing files at {}",
3266            generation_dir.display()
3267        )));
3268    }
3269    Ok(())
3270}
3271
3272fn project_bin_generation_id(
3273    selections: &[ProjectNpmBinSelection],
3274    bins: &[ProjectNpmBinManifestEntry],
3275) -> Result<String> {
3276    let identity = serde_json::to_vec(&(
3277        PROJECT_NPM_BIN_SCHEMA,
3278        project_bin_platform(),
3279        selections,
3280        bins,
3281    ))?;
3282    Ok(pipeline::verify::hash_bytes(
3283        &identity,
3284        pipeline::HashAlgo::Sha256,
3285    ))
3286}
3287
3288fn declared_project_bin_entries(
3289    canonical_project: &Path,
3290    selections: &[ProjectNpmBinSelection],
3291) -> Result<Vec<(ProjectNpmBinManifestEntry, PathBuf)>> {
3292    let mut resolved = Vec::new();
3293    let mut seen_backends = BTreeSet::new();
3294    let mut seen_bins = BTreeSet::new();
3295    let mut previous_backend = None;
3296    for selection in selections {
3297        if selection.configured_spec.trim().is_empty()
3298            || selection.version.trim().is_empty()
3299            || !seen_backends.insert(selection.backend.clone())
3300            || previous_backend
3301                .as_ref()
3302                .is_some_and(|previous| previous >= &selection.backend)
3303        {
3304            return Err(Error::other("invalid project npm bin selection manifest"));
3305        }
3306        previous_backend = Some(selection.backend.clone());
3307        let backend = NpmPackageBackend::from_id(&selection.backend)
3308            .filter(|backend| backend.id() == selection.backend)
3309            .ok_or_else(|| Error::other("invalid npm backend in project bin manifest"))?;
3310        let package_dir = package_install_dir(canonical_project, backend.package());
3311        let canonical_package =
3312            dunce::canonicalize(&package_dir).map_err(|error| Error::io(&package_dir, error))?;
3313        if !canonical_package.starts_with(canonical_project) {
3314            return Err(Error::other(format!(
3315                "installed npm package {} resolves outside project",
3316                backend.package()
3317            )));
3318        }
3319        let package_json = package_dir.join("package.json");
3320        let bytes = crate::inventory::read_stable_regular_file(
3321            &package_json,
3322            NPM_PACKAGE_MANIFEST_MAX_BYTES,
3323        )
3324        .map_err(|error| Error::io(&package_json, error))?;
3325        let package_manifest: serde_json::Value = serde_json::from_slice(&bytes)?;
3326        if package_manifest
3327            .get("name")
3328            .and_then(serde_json::Value::as_str)
3329            != Some(backend.package())
3330            || package_manifest
3331                .get("version")
3332                .and_then(serde_json::Value::as_str)
3333                != Some(selection.version.as_str())
3334        {
3335            return Err(Error::other(format!(
3336                "installed project package identity mismatch: expected {}@{}",
3337                backend.package(),
3338                selection.version
3339            )));
3340        }
3341        for (name, relative) in package_bin_entries(&package_manifest, backend.package())? {
3342            if relative.is_absolute()
3343                || relative.as_os_str().is_empty()
3344                || relative.components().any(|component| {
3345                    matches!(
3346                        component,
3347                        std::path::Component::ParentDir
3348                            | std::path::Component::RootDir
3349                            | std::path::Component::Prefix(_)
3350                    )
3351                })
3352            {
3353                return Err(Error::other(format!(
3354                    "npm package {} declares unsafe bin path {}",
3355                    backend.package(),
3356                    relative.display()
3357                )));
3358            }
3359            if !seen_bins.insert(project_bin_conflict_key(&name)) {
3360                return Err(Error::other(format!(
3361                    "project npm bin `{name}` is declared by more than one configured package"
3362                )));
3363            }
3364            let declared = package_dir.join(relative);
3365            let target =
3366                dunce::canonicalize(&declared).map_err(|error| Error::io(&declared, error))?;
3367            if !target.is_file()
3368                || !target.starts_with(&canonical_package)
3369                || !target.starts_with(canonical_project)
3370            {
3371                return Err(Error::other(format!(
3372                    "project npm bin target escapes its package: {}",
3373                    declared.display()
3374                )));
3375            }
3376            let entry = ProjectNpmBinManifestEntry {
3377                name,
3378                backend: selection.backend.clone(),
3379                target: portable_relative_path(
3380                    target
3381                        .strip_prefix(canonical_project)
3382                        .map_err(|_| Error::other("project npm bin target escapes project"))?,
3383                )?,
3384            };
3385            resolved.push((entry, target));
3386        }
3387    }
3388    resolved.sort_by(|left, right| left.0.name.cmp(&right.0.name));
3389    Ok(resolved)
3390}
3391
3392fn project_bin_file_set(root: &Path) -> Result<BTreeSet<String>> {
3393    let mut files = BTreeSet::new();
3394    for entry in walkdir::WalkDir::new(root).min_depth(1).follow_links(false) {
3395        let entry = entry.map_err(|error| {
3396            Error::other(format!(
3397                "reading project npm bin generation {}: {error}",
3398                root.display()
3399            ))
3400        })?;
3401        let relative = entry
3402            .path()
3403            .strip_prefix(root)
3404            .map_err(|_| Error::other("project npm bin entry escaped its generation"))?;
3405        let portable = portable_relative_path(relative)?;
3406        let metadata = std::fs::symlink_metadata(entry.path())
3407            .map_err(|error| Error::io(entry.path(), error))?;
3408        if metadata.is_dir() {
3409            if portable != PROJECT_NPM_BIN_BIN_DIR {
3410                return Err(Error::other(format!(
3411                    "unexpected directory in project npm bin generation: {portable}"
3412                )));
3413            }
3414        } else if !metadata.is_file() && !metadata.file_type().is_symlink() {
3415            return Err(Error::other(format!(
3416                "unsupported file type in project npm bin generation: {portable}"
3417            )));
3418        }
3419        files.insert(portable);
3420    }
3421    Ok(files)
3422}
3423
3424fn valid_generation_id(value: &str) -> bool {
3425    value.len() == 64
3426        && value
3427            .bytes()
3428            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
3429}
3430
3431fn project_bin_platform() -> &'static str {
3432    if cfg!(windows) {
3433        "windows"
3434    } else {
3435        "unix"
3436    }
3437}
3438
3439fn project_bin_conflict_key(name: &str) -> String {
3440    if cfg!(windows) {
3441        name.to_ascii_lowercase()
3442    } else {
3443        name.to_string()
3444    }
3445}
3446
3447fn portable_relative_path(path: &Path) -> Result<String> {
3448    if path.as_os_str().is_empty()
3449        || path.is_absolute()
3450        || path.components().any(|component| {
3451            !matches!(
3452                component,
3453                std::path::Component::Normal(_) | std::path::Component::CurDir
3454            )
3455        })
3456    {
3457        return Err(Error::other(format!(
3458            "unsafe project npm bin relative path: {}",
3459            path.display()
3460        )));
3461    }
3462    Ok(path.to_string_lossy().replace('\\', "/"))
3463}
3464
3465fn path_from_portable(value: &str) -> Result<PathBuf> {
3466    if value.is_empty() || value.contains('\\') {
3467        return Err(Error::other("invalid project npm bin target path"));
3468    }
3469    let path = PathBuf::from(value);
3470    portable_relative_path(&path)?;
3471    Ok(path)
3472}
3473
3474fn curated_launcher_name(name: &str) -> String {
3475    if cfg!(windows) {
3476        format!("{name}.cmd")
3477    } else {
3478        name.to_string()
3479    }
3480}
3481
3482#[cfg(not(windows))]
3483fn write_curated_project_launcher(bin_dir: &Path, name: &str, target: &Path) -> Result<()> {
3484    use std::os::unix::fs::symlink;
3485
3486    let launcher = bin_dir.join(name);
3487    let relative = relative_path_from(bin_dir, target).ok_or_else(|| {
3488        Error::other(format!(
3489            "cannot construct relative project npm bin target for {}",
3490            target.display()
3491        ))
3492    })?;
3493    symlink(&relative, &launcher).map_err(|error| Error::io(&launcher, error))
3494}
3495
3496#[cfg(windows)]
3497fn write_curated_project_launcher(bin_dir: &Path, name: &str, target: &Path) -> Result<()> {
3498    let launcher = bin_dir.join(format!("{name}.cmd"));
3499    let relative = relative_path_from(bin_dir, target).ok_or_else(|| {
3500        Error::other(format!(
3501            "cannot construct relative project npm bin target for {}",
3502            target.display()
3503        ))
3504    })?;
3505    let target = relative.to_string_lossy().replace('/', "\\");
3506    let contents = render_osdk_project_cmd_wrapper(&target)?;
3507    std::fs::write(&launcher, contents).map_err(|error| Error::io(&launcher, error))
3508}
3509
3510fn validate_curated_project_launcher(launcher: &Path, target: &Path) -> Result<()> {
3511    #[cfg(not(windows))]
3512    {
3513        let metadata =
3514            std::fs::symlink_metadata(launcher).map_err(|error| Error::io(launcher, error))?;
3515        if !metadata.file_type().is_symlink()
3516            || dunce::canonicalize(launcher).map_err(|error| Error::io(launcher, error))? != target
3517        {
3518            return Err(Error::other(format!(
3519                "invalid curated project npm launcher at {}",
3520                launcher.display()
3521            )));
3522        }
3523        Ok(())
3524    }
3525    #[cfg(windows)]
3526    {
3527        let actual = parse_windows_project_wrapper(launcher)?;
3528        let actual = dunce::canonicalize(&actual).map_err(|error| Error::io(&actual, error))?;
3529        if actual != target {
3530            return Err(Error::other(format!(
3531                "invalid curated project npm launcher at {}",
3532                launcher.display()
3533            )));
3534        }
3535        Ok(())
3536    }
3537}
3538
3539fn relative_path_from(base: &Path, target: &Path) -> Option<PathBuf> {
3540    let base = base.components().collect::<Vec<_>>();
3541    let target = target.components().collect::<Vec<_>>();
3542    let shared = base
3543        .iter()
3544        .zip(&target)
3545        .take_while(|(left, right)| left == right)
3546        .count();
3547    if shared == 0 {
3548        return None;
3549    }
3550    let mut relative = PathBuf::new();
3551    for _ in shared..base.len() {
3552        relative.push("..");
3553    }
3554    for component in &target[shared..] {
3555        relative.push(component.as_os_str());
3556    }
3557    Some(relative)
3558}
3559
3560fn read_project_bin_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
3561    let bytes = crate::inventory::read_stable_regular_file(path, PROJECT_NPM_BIN_MAX_JSON_BYTES)
3562        .map_err(|error| Error::io(path, error))?;
3563    serde_json::from_slice(&bytes).map_err(Into::into)
3564}
3565
3566fn write_project_bin_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
3567    let parent = path.parent().ok_or_else(|| {
3568        Error::other(format!(
3569            "project npm bin path has no parent: {}",
3570            path.display()
3571        ))
3572    })?;
3573    std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
3574    let temporary = unique_project_bin_path(parent, "metadata");
3575    let bytes = serde_json::to_vec_pretty(value)?;
3576    {
3577        use std::io::Write;
3578        let mut file = std::fs::OpenOptions::new()
3579            .create_new(true)
3580            .write(true)
3581            .open(&temporary)
3582            .map_err(|error| Error::io(&temporary, error))?;
3583        file.write_all(&bytes)
3584            .map_err(|error| Error::io(&temporary, error))?;
3585        file.sync_all()
3586            .map_err(|error| Error::io(&temporary, error))?;
3587    }
3588    if let Err(error) = atomic_replace_project_bin(&temporary, path) {
3589        let _ = std::fs::remove_file(&temporary);
3590        return Err(error);
3591    }
3592    Ok(())
3593}
3594
3595fn unique_project_bin_path(parent: &Path, label: &str) -> PathBuf {
3596    loop {
3597        let nonce = NEXT_PROJECT_NPM_BIN_TEMPORARY.fetch_add(1, Ordering::Relaxed);
3598        let candidate = parent.join(format!(".{label}-{}-{nonce}", std::process::id()));
3599        if !candidate.exists() {
3600            return candidate;
3601        }
3602    }
3603}
3604
3605#[cfg(not(windows))]
3606fn atomic_replace_project_bin(source: &Path, destination: &Path) -> Result<()> {
3607    std::fs::rename(source, destination).map_err(|error| Error::io(destination, error))
3608}
3609
3610#[cfg(windows)]
3611fn atomic_replace_project_bin(source: &Path, destination: &Path) -> Result<()> {
3612    use std::os::windows::ffi::OsStrExt;
3613    use windows_sys::Win32::Storage::FileSystem::{
3614        MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
3615    };
3616
3617    let source_wide = source
3618        .as_os_str()
3619        .encode_wide()
3620        .chain(Some(0))
3621        .collect::<Vec<_>>();
3622    let destination_wide = destination
3623        .as_os_str()
3624        .encode_wide()
3625        .chain(Some(0))
3626        .collect::<Vec<_>>();
3627    let result = unsafe {
3628        MoveFileExW(
3629            source_wide.as_ptr(),
3630            destination_wide.as_ptr(),
3631            MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
3632        )
3633    };
3634    if result == 0 {
3635        return Err(Error::io(destination, std::io::Error::last_os_error()));
3636    }
3637    Ok(())
3638}
3639
3640#[cfg(test)]
3641mod tests {
3642    use super::*;
3643
3644    #[test]
3645    fn parses_scoped_and_unscoped_names() {
3646        let unscoped = NpmPackageBackend::from_id("npm:Prettier").unwrap();
3647        assert_eq!(unscoped.id, "npm:prettier");
3648        assert_eq!(unscoped.package, "prettier");
3649        let scoped = NpmPackageBackend::from_id("npm:@Antfu/Ni").unwrap();
3650        assert_eq!(scoped.id, "npm:@antfu/ni");
3651        assert_eq!(scoped.package, "@antfu/ni");
3652        assert!(NpmPackageBackend::from_id("npm:@antfu").is_none());
3653        assert!(NpmPackageBackend::from_id("npm:@antfu/ni/extra").is_none());
3654        for invalid in [
3655            "npm:foo#bar",
3656            "npm:foo?bar",
3657            "npm:foo%2fbar",
3658            "npm:foo bar",
3659            "npm:foo\\bar",
3660            "npm:.",
3661            "npm:..",
3662            "npm:CON",
3663            "npm:@scope/AUX",
3664        ] {
3665            assert!(NpmPackageBackend::from_id(invalid).is_none(), "{invalid}");
3666        }
3667        assert!(NpmPackageBackend::from_id(&format!("npm:{}", "a".repeat(215))).is_none());
3668    }
3669
3670    #[cfg(unix)]
3671    fn write_project_package_fixture(
3672        project: &Path,
3673        package: &str,
3674        version: &str,
3675        bin_name: &str,
3676    ) -> PathBuf {
3677        let package_dir = package_install_dir(project, package);
3678        let target = package_dir.join("bin/tool.js");
3679        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
3680        std::fs::create_dir_all(project.join("node_modules/.bin")).unwrap();
3681        std::fs::write(&target, b"#!/usr/bin/env node\n").unwrap();
3682        std::fs::write(
3683            package_dir.join("package.json"),
3684            serde_json::to_vec(&serde_json::json!({
3685                "name": package,
3686                "version": version,
3687                "bin": { bin_name: "bin/tool.js" }
3688            }))
3689            .unwrap(),
3690        )
3691        .unwrap();
3692        target
3693    }
3694
3695    #[test]
3696    fn osdk_windows_project_wrapper_round_trips_exact_target() {
3697        let target = "..\\..\\..\\node_modules\\prettier\\bin\\prettier.cjs";
3698        let wrapper = render_osdk_project_cmd_wrapper(target).unwrap();
3699
3700        assert_eq!(
3701            wrapper,
3702            format!("@echo off\r\n{OSDK_PROJECT_NPM_CMD_MARKER}\r\nnode \"%~dp0{target}\" %*\r\n")
3703        );
3704        assert_eq!(parse_osdk_project_cmd_wrapper(&wrapper), Some(target));
3705    }
3706
3707    #[test]
3708    fn osdk_windows_project_wrapper_rejects_appended_commands() {
3709        let target = "..\\pkg\\cli.js";
3710        let wrapper = render_osdk_project_cmd_wrapper(target).unwrap();
3711
3712        assert!(parse_osdk_project_cmd_wrapper(&format!("{wrapper}calc.exe\r\n")).is_none());
3713        assert!(
3714            parse_osdk_project_cmd_wrapper(&wrapper.replace(" %*", " %* & calc.exe")).is_none()
3715        );
3716        assert!(render_osdk_project_cmd_wrapper("..\\pkg\\cli.js&calc.exe").is_err());
3717    }
3718
3719    #[cfg(unix)]
3720    #[test]
3721    fn project_package_bins_accept_exact_symlink_and_reject_opaque_regular_file() {
3722        use std::os::unix::fs::symlink;
3723
3724        let temporary = tempfile::tempdir().unwrap();
3725        let project = temporary.path();
3726        let target = write_project_package_fixture(project, "prettier", "3.6.2", "prettier");
3727        let launcher = project.join("node_modules/.bin/prettier");
3728        symlink(&target, &launcher).unwrap();
3729        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
3730        assert_eq!(
3731            backend
3732                .validate_project_package_bins(project, "3.6.2")
3733                .unwrap(),
3734            vec!["prettier"]
3735        );
3736
3737        std::fs::remove_file(&launcher).unwrap();
3738        std::fs::write(&launcher, b"#!/bin/sh\necho not-the-target\n").unwrap();
3739        assert!(backend
3740            .validate_project_package_bins(project, "3.6.2")
3741            .is_err());
3742
3743        std::fs::write(
3744            &launcher,
3745            format!("#!/bin/sh\nexec node \"{}\" \"$@\"\n", target.display()),
3746        )
3747        .unwrap();
3748        assert_eq!(
3749            backend
3750                .validate_project_package_bins(project, "3.6.2")
3751                .unwrap(),
3752            vec!["prettier"]
3753        );
3754    }
3755
3756    #[cfg(unix)]
3757    #[test]
3758    fn curated_project_bin_generation_is_persistent_and_rejects_extra_files() {
3759        use std::os::unix::fs::symlink;
3760
3761        let temporary = tempfile::tempdir().unwrap();
3762        let project = temporary.path();
3763        let target = write_project_package_fixture(project, "prettier", "3.6.2", "prettier");
3764        symlink(&target, project.join("node_modules/.bin/prettier")).unwrap();
3765        let selection = ProjectNpmBinSelection {
3766            backend: "npm:prettier".into(),
3767            configured_spec: "^3.6".into(),
3768            version: "3.6.2".into(),
3769        };
3770        let configured =
3771            BTreeMap::from([(selection.backend.clone(), selection.configured_spec.clone())]);
3772        let bin = publish_project_bin_generation(project, &selection, &configured).unwrap();
3773        assert_eq!(
3774            validated_project_bin_dir(project, &configured).unwrap(),
3775            Some(bin.clone())
3776        );
3777
3778        let configured_with_unpublished = BTreeMap::from([
3779            (selection.backend.clone(), selection.configured_spec.clone()),
3780            ("npm:legacy".into(), "1".into()),
3781        ]);
3782        assert_eq!(
3783            validated_project_bin_dir(project, &configured_with_unpublished).unwrap(),
3784            Some(bin.clone())
3785        );
3786
3787        std::fs::write(bin.join("unexpected"), b"payload").unwrap();
3788        assert!(validated_project_bin_dir(project, &configured).is_err());
3789    }
3790
3791    #[cfg(unix)]
3792    #[test]
3793    fn curated_project_bin_generation_rejects_symlinked_owned_root() {
3794        use std::os::unix::fs::symlink;
3795
3796        let temporary = tempfile::tempdir().unwrap();
3797        let outside = tempfile::tempdir().unwrap();
3798        let project = temporary.path();
3799        let target = write_project_package_fixture(project, "prettier", "3.6.2", "prettier");
3800        symlink(&target, project.join("node_modules/.bin/prettier")).unwrap();
3801        symlink(outside.path(), project.join(".osdk")).unwrap();
3802        let selection = ProjectNpmBinSelection {
3803            backend: "npm:prettier".into(),
3804            configured_spec: "3.6.2".into(),
3805            version: "3.6.2".into(),
3806        };
3807        let configured =
3808            BTreeMap::from([(selection.backend.clone(), selection.configured_spec.clone())]);
3809
3810        assert!(publish_project_bin_generation(project, &selection, &configured).is_err());
3811        assert!(std::fs::read_dir(outside.path()).unwrap().next().is_none());
3812    }
3813
3814    #[test]
3815    fn curated_project_selection_enforces_npm_version_constraints() {
3816        for (spec, exact) in [
3817            ("3.6.2", "3.6.2"),
3818            ("v3.6.2", "3.6.2"),
3819            ("3", "3.6.2"),
3820            ("3.6", "3.6.2"),
3821            ("^3.6.0", "3.9.1"),
3822            ("~3.6.0", "3.6.9"),
3823            (">=3.6.0 <4", "3.8.0"),
3824            ("^2 || ^3.6", "3.7.0"),
3825        ] {
3826            assert_eq!(
3827                npm_spec_satisfaction(spec, exact),
3828                Some(true),
3829                "{spec} -> {exact}"
3830            );
3831        }
3832
3833        for (spec, exact) in [
3834            ("3.6.2", "3.6.3"),
3835            ("3.6", "3.7.0"),
3836            ("^3.6.0", "4.0.0"),
3837            ("~3.6.0", "3.7.0"),
3838            (">=3.6.0 <4", "4.0.0"),
3839            ("^3.6.0", "not-exact"),
3840        ] {
3841            assert_eq!(
3842                npm_spec_satisfaction(spec, exact),
3843                Some(false),
3844                "{spec} -> {exact}"
3845            );
3846        }
3847        for channel in ["latest", "beta", "next-1"] {
3848            assert_eq!(npm_spec_satisfaction(channel, "3.6.2"), None);
3849        }
3850        assert_eq!(npm_spec_satisfaction("workspace:*", "3.6.2"), None);
3851        assert_eq!(npm_spec_satisfaction("not a range", "3.6.2"), None);
3852    }
3853
3854    #[test]
3855    fn curated_project_channel_requires_matching_project_lock() {
3856        let temporary = tempfile::tempdir().unwrap();
3857        let project = temporary.path();
3858        let selection = ProjectNpmBinSelection {
3859            backend: "npm:prettier".into(),
3860            configured_spec: "latest".into(),
3861            version: "3.6.2".into(),
3862        };
3863        let configured =
3864            BTreeMap::from([(selection.backend.clone(), selection.configured_spec.clone())]);
3865        assert!(
3866            validate_project_npm_selection_for_project(&selection, &configured, project).is_err()
3867        );
3868
3869        std::fs::write(
3870            project.join(PROJECT_NPM_LOCKFILE),
3871            r#"
3872schema = 3
3873
3874[platforms.linux-x64.tools."npm:prettier"]
3875request = "latest"
3876version = "3.6.2"
3877
3878[platforms.linux-x64.tools."npm:prettier".npm]
3879package = "prettier"
3880installer = "aube"
3881scope = "project"
3882"#,
3883        )
3884        .unwrap();
3885        validate_project_npm_selection_for_project(&selection, &configured, project).unwrap();
3886
3887        let mismatched = ProjectNpmBinSelection {
3888            version: "3.6.3".into(),
3889            ..selection
3890        };
3891        assert!(
3892            validate_project_npm_selection_for_project(&mismatched, &configured, project).is_err()
3893        );
3894    }
3895
3896    #[test]
3897    fn aube_storage_is_shared_across_packages_versions_and_scopes() {
3898        let temporary = tempfile::tempdir().unwrap();
3899        let ctx = offline_test_ctx(temporary.path());
3900        let cases = [
3901            ("npm:prettier", "3.6.2"),
3902            ("npm:prettier", "3.6.1"),
3903            ("npm:@antfu/ni", "0.21.12"),
3904        ];
3905        let paths = cases.map(|(id, version)| {
3906            let backend = NpmPackageBackend::from_id(id).unwrap();
3907            let env = backend
3908                .exec_env(&ctx, &ToolVersion::new(id, version))
3909                .unwrap();
3910            (
3911                PathBuf::from(&env["npm_config_cache"]),
3912                PathBuf::from(&env["npm_config_store_dir"]),
3913            )
3914        });
3915
3916        assert!(paths.iter().all(|path| path == &paths[0]));
3917        assert_eq!(paths[0].0, temporary.path().join("cache/aube/v1/cache"));
3918        assert_eq!(paths[0].1, temporary.path().join("store/aube"));
3919    }
3920
3921    #[test]
3922    fn isolated_and_global_install_roots_are_distinct_for_the_same_identity() {
3923        let temporary = tempfile::tempdir().unwrap();
3924        let ctx = offline_test_ctx(temporary.path());
3925        let backend = NpmPackageBackend::from_id("npm:@antfu/ni").unwrap();
3926        let version = npm_test_version(&backend, "1.0.0");
3927        let isolated = backend
3928            .install_locator(&ctx, &version, ToolScope::Project)
3929            .unwrap();
3930        let global = backend
3931            .install_locator(&ctx, &version, ToolScope::Global)
3932            .unwrap();
3933
3934        assert_ne!(isolated.install_root(), global.install_root());
3935        assert_eq!(
3936            isolated.install_root(),
3937            backend
3938                .legacy_isolated_install_root(&ctx, &version.version)
3939                .join(crate::dirs::install_id_component(&isolated.identity().install_id).unwrap())
3940        );
3941        assert_eq!(
3942            global.install_root(),
3943            backend
3944                .legacy_global_install_root_path(&ctx, &version.version)
3945                .join(crate::dirs::install_id_component(&global.identity().install_id).unwrap())
3946        );
3947        assert_eq!(isolated.identity().scope, InstallScope::Isolated);
3948        assert_eq!(global.identity().scope, InstallScope::Global);
3949    }
3950
3951    const TEST_NODE_VERSION: &str = "20.10.0";
3952
3953    fn npm_test_version(backend: &NpmPackageBackend, version: &str) -> ToolVersion {
3954        npm_test_version_with_node(backend, version, TEST_NODE_VERSION)
3955    }
3956
3957    fn npm_test_version_with_node(
3958        backend: &NpmPackageBackend,
3959        version: &str,
3960        node_version: &str,
3961    ) -> ToolVersion {
3962        let mut tool = ToolVersion::new(backend.id(), version);
3963        tool.options
3964            .insert(LOCKED_NPM_NODE_VERSION_OPTION.into(), node_version.into());
3965        tool
3966    }
3967
3968    fn with_scope(mut version: ToolVersion, scope: ToolScope) -> ToolVersion {
3969        version
3970            .options
3971            .insert(LOCKED_NPM_SCOPE_OPTION.into(), scope.as_str().into());
3972        version
3973    }
3974
3975    fn write_scope_fixture(
3976        backend: &NpmPackageBackend,
3977        ctx: &Ctx,
3978        version: &ToolVersion,
3979        scope: ToolScope,
3980        bin_name: &str,
3981    ) -> PathBuf {
3982        let locator = backend.install_locator(ctx, version, scope).unwrap();
3983        let root = locator.install_root().to_path_buf();
3984        let relative_bin = match scope {
3985            ToolScope::Project => format!("project/node_modules/.bin/{bin_name}"),
3986            ToolScope::Global => format!("bin/{bin_name}"),
3987        };
3988        let bin = root.join(&relative_bin);
3989        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
3990        std::fs::write(&bin, b"fixture").unwrap();
3991        #[cfg(unix)]
3992        {
3993            use std::os::unix::fs::PermissionsExt;
3994            std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
3995        }
3996        if scope == ToolScope::Project {
3997            let project = root.join(PROJECT_DIR);
3998            NpmPackageBackend::write_project_manifest(
3999                &project,
4000                Some((backend.package(), &version.version)),
4001                &NpmPackageBackend::build_policy(version).unwrap(),
4002            )
4003            .unwrap();
4004            std::fs::create_dir_all(package_install_dir(&project, backend.package())).unwrap();
4005            let lockfile = npm_test_lockfile(
4006                backend.package(),
4007                &version.version,
4008                "sha512-fixture-integrity",
4009            );
4010            std::fs::write(project.join(AUBE_LOCKFILE_NAME), &lockfile).unwrap();
4011        } else {
4012            let project = root.join(PROJECT_DIR);
4013            let package = package_install_dir(&project, backend.package());
4014            std::fs::create_dir_all(&package).unwrap();
4015            std::fs::write(
4016                package.join("package.json"),
4017                serde_json::to_vec(&serde_json::json!({
4018                    "name": backend.package(),
4019                    "version": version.version,
4020                }))
4021                .unwrap(),
4022            )
4023            .unwrap();
4024            std::fs::write(
4025                project.join(AUBE_LOCKFILE_NAME),
4026                npm_test_lockfile(
4027                    backend.package(),
4028                    &version.version,
4029                    "sha512-fixture-integrity",
4030                ),
4031            )
4032            .unwrap();
4033        }
4034        write_node_fixture(ctx, &version.options[LOCKED_NPM_NODE_VERSION_OPTION]);
4035        let mut manifest = DynamicToolManifest::from_identity(locator.identity().clone()).unwrap();
4036        manifest.bins = vec![DynamicToolBin {
4037            name: bin_name.into(),
4038            path: relative_bin,
4039        }];
4040        manifest.write_atomic(&root).unwrap();
4041        write_npm_receipt(
4042            &root,
4043            &NpmInstallReceipt {
4044                schema: NPM_INSTALL_RECEIPT_SCHEMA,
4045                provider: PROVIDER.into(),
4046                package: backend.package().into(),
4047                installer: "aube".into(),
4048                node_version: version.options[LOCKED_NPM_NODE_VERSION_OPTION].clone(),
4049                build_policy: NpmPackageBackend::build_policy(version).unwrap().identity(),
4050                graph_sha256: (scope == ToolScope::Project).then(|| {
4051                    pipeline::verify::hash_file(
4052                        &root.join(PROJECT_DIR).join(AUBE_LOCKFILE_NAME),
4053                        pipeline::HashAlgo::Sha256,
4054                    )
4055                    .unwrap()
4056                }),
4057                root_integrity: (scope == ToolScope::Project)
4058                    .then(|| "sha512-fixture-integrity".into()),
4059                root_source: (scope == ToolScope::Project)
4060                    .then(|| format!("npm:{}@{}", backend.package(), version.version)),
4061                native_lock_format: (scope == ToolScope::Global).then(|| AUBE_LOCK_FORMAT.into()),
4062                native_lock_sha256: (scope == ToolScope::Global).then(|| {
4063                    pipeline::verify::hash_file(
4064                        &root.join(PROJECT_DIR).join(AUBE_LOCKFILE_NAME),
4065                        pipeline::HashAlgo::Sha256,
4066                    )
4067                    .unwrap()
4068                }),
4069            },
4070        )
4071        .unwrap();
4072        std::fs::write(root.join(".osdk-complete"), b"").unwrap();
4073        root
4074    }
4075
4076    fn write_node_fixture(ctx: &Ctx, version: &str) -> PathBuf {
4077        let root = ctx.dirs.install_path("node", version);
4078        let bin = if cfg!(windows) {
4079            root.join(node_executable_name())
4080        } else {
4081            root.join("bin").join(node_executable_name())
4082        };
4083        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
4084        std::fs::write(&bin, b"node fixture").unwrap();
4085        #[cfg(unix)]
4086        {
4087            use std::os::unix::fs::PermissionsExt;
4088            std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
4089        }
4090        std::fs::write(root.join(".osdk-complete"), b"").unwrap();
4091        bin
4092    }
4093
4094    #[test]
4095    fn completed_selection_rejects_marker_symlink_tampered_receipt_and_missing_node() {
4096        let temporary = tempfile::tempdir().unwrap();
4097        let ctx = offline_test_ctx(temporary.path());
4098        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
4099        let version = npm_test_version(&backend, "3.6.2");
4100        let root = write_scope_fixture(&backend, &ctx, &version, ToolScope::Project, "prettier");
4101
4102        assert_eq!(
4103            backend.selected_install_root(&ctx, &version).unwrap(),
4104            Some(root.clone())
4105        );
4106
4107        let marker = root.join(".osdk-complete");
4108        std::fs::remove_file(&marker).unwrap();
4109        #[cfg(unix)]
4110        {
4111            let marker_target = temporary.path().join("complete");
4112            std::fs::write(&marker_target, b"").unwrap();
4113            std::os::unix::fs::symlink(&marker_target, &marker).unwrap();
4114            assert!(backend
4115                .selected_install_root(&ctx, &version)
4116                .unwrap()
4117                .is_none());
4118            std::fs::remove_file(&marker).unwrap();
4119        }
4120        std::fs::write(&marker, b"").unwrap();
4121
4122        let mut receipt = load_npm_receipt(&root).unwrap();
4123        receipt.package = "typescript".into();
4124        write_npm_receipt(&root, &receipt).unwrap();
4125        assert!(backend
4126            .selected_install_root(&ctx, &version)
4127            .unwrap()
4128            .is_none());
4129
4130        receipt.package = backend.package().into();
4131        write_npm_receipt(&root, &receipt).unwrap();
4132        std::fs::remove_dir_all(ctx.dirs.install_path("node", TEST_NODE_VERSION)).unwrap();
4133        assert!(backend
4134            .selected_install_root(&ctx, &version)
4135            .unwrap()
4136            .is_none());
4137        assert!(backend
4138            .list_installed_for(&ctx, &version)
4139            .unwrap()
4140            .is_empty());
4141    }
4142
4143    #[cfg(unix)]
4144    #[test]
4145    fn completed_selection_rejects_symlinked_or_oversized_receipt_and_native_lock() {
4146        use std::os::unix::fs::symlink;
4147
4148        let temporary = tempfile::tempdir().unwrap();
4149        let ctx = offline_test_ctx(temporary.path());
4150        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
4151        let version = npm_test_version(&backend, "3.6.2");
4152
4153        let isolated =
4154            write_scope_fixture(&backend, &ctx, &version, ToolScope::Project, "prettier");
4155        let receipt_path = npm_receipt_path(&isolated);
4156        let receipt_copy = temporary.path().join("receipt.json");
4157        std::fs::copy(&receipt_path, &receipt_copy).unwrap();
4158        std::fs::remove_file(&receipt_path).unwrap();
4159        symlink(&receipt_copy, &receipt_path).unwrap();
4160        assert!(!backend
4161            .validate_completed_install(&ctx, &version, ToolScope::Project, &isolated)
4162            .unwrap());
4163        std::fs::remove_file(&receipt_path).unwrap();
4164        std::fs::write(
4165            &receipt_path,
4166            vec![b'x'; (NPM_INSTALL_RECEIPT_MAX_BYTES + 1) as usize],
4167        )
4168        .unwrap();
4169        assert!(!backend
4170            .validate_completed_install(&ctx, &version, ToolScope::Project, &isolated)
4171            .unwrap());
4172
4173        let global_version = with_scope(version.clone(), ToolScope::Global);
4174        let global = write_scope_fixture(
4175            &backend,
4176            &ctx,
4177            &global_version,
4178            ToolScope::Global,
4179            "prettier",
4180        );
4181        let lock_path = global.join(PROJECT_DIR).join(AUBE_LOCKFILE_NAME);
4182        let lock_copy = temporary.path().join("native-lock.yaml");
4183        std::fs::copy(&lock_path, &lock_copy).unwrap();
4184        std::fs::remove_file(&lock_path).unwrap();
4185        symlink(&lock_copy, &lock_path).unwrap();
4186        assert!(!backend
4187            .validate_completed_install(&ctx, &global_version, ToolScope::Global, &global)
4188            .unwrap());
4189    }
4190
4191    #[test]
4192    fn installed_version_listing_preserves_manifest_node_identity_after_active_node_changes() {
4193        let temporary = tempfile::tempdir().unwrap();
4194        let mut ctx = offline_test_ctx(temporary.path());
4195        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
4196        let installed = npm_test_version_with_node(&backend, "3.6.2", "20.10.0");
4197        let root = write_scope_fixture(&backend, &ctx, &installed, ToolScope::Project, "prettier");
4198        write_node_fixture(&ctx, "22.14.0");
4199        ctx.config.tools.insert("node".into(), "22.14.0".into());
4200
4201        let unbound = ToolVersion::new(backend.id(), "selection");
4202        assert_eq!(
4203            backend.list_installed_for(&ctx, &unbound).unwrap(),
4204            vec!["3.6.2"]
4205        );
4206
4207        let selected = ToolVersion::new(backend.id(), "3.6.2");
4208        assert_ne!(
4209            backend.isolated_install_root_for(&ctx, &selected).unwrap(),
4210            root
4211        );
4212        assert!(backend
4213            .selected_install_root(&ctx, &selected)
4214            .unwrap()
4215            .is_none());
4216
4217        assert_eq!(
4218            backend.selected_install_root(&ctx, &installed).unwrap(),
4219            Some(root)
4220        );
4221    }
4222
4223    #[test]
4224    fn installed_identity_listing_preserves_manifest_node_identity_after_active_node_changes() {
4225        let temporary = tempfile::tempdir().unwrap();
4226        let mut ctx = offline_test_ctx(temporary.path());
4227        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
4228        let installed = npm_test_version_with_node(&backend, "3.6.2", "20.10.0");
4229        let root = write_scope_fixture(&backend, &ctx, &installed, ToolScope::Project, "prettier");
4230        write_node_fixture(&ctx, "22.14.0");
4231        ctx.config.tools.insert("node".into(), "22.14.0".into());
4232
4233        let hint = ToolVersion::new(backend.id(), "selection");
4234        let candidates = backend.list_installed_identities_for(&ctx, &hint).unwrap();
4235
4236        assert_eq!(candidates.len(), 1);
4237        assert_eq!(candidates[0].version, "3.6.2");
4238        assert_eq!(
4239            candidates[0].options[LOCKED_NPM_NODE_VERSION_OPTION],
4240            "20.10.0"
4241        );
4242        assert_eq!(
4243            backend
4244                .where_install_root_for(&ctx, &candidates[0])
4245                .unwrap(),
4246            Some(root)
4247        );
4248    }
4249
4250    #[test]
4251    fn same_version_option_and_runtime_variants_have_distinct_roots() {
4252        let temporary = tempfile::tempdir().unwrap();
4253        let ctx = offline_test_ctx(temporary.path());
4254        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
4255        let baseline = npm_test_version(&backend, "3.6.2");
4256        let mut installer = baseline.clone();
4257        installer.options.insert("installer".into(), "aube".into());
4258        let mut builds = baseline.clone();
4259        builds
4260            .options
4261            .insert("allow_builds".into(), "esbuild".into());
4262        let mut newer_node = baseline.clone();
4263        newer_node
4264            .options
4265            .insert(LOCKED_NPM_NODE_VERSION_OPTION.into(), "22.14.0".into());
4266
4267        let roots = [&baseline, &installer, &builds, &newer_node]
4268            .map(|version| backend.isolated_install_root_for(&ctx, version).unwrap());
4269        for left in 0..roots.len() {
4270            for right in left + 1..roots.len() {
4271                assert_ne!(roots[left], roots[right]);
4272            }
4273        }
4274    }
4275
4276    #[tokio::test]
4277    async fn scopes_coexist_and_uninstall_commands_are_isolated() {
4278        let temporary = tempfile::tempdir().unwrap();
4279        let ctx = offline_test_ctx(temporary.path());
4280        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
4281        let version = npm_test_version(&backend, "3.6.2");
4282        let isolated =
4283            write_scope_fixture(&backend, &ctx, &version, ToolScope::Project, "isolated-bin");
4284        let global = write_scope_fixture(&backend, &ctx, &version, ToolScope::Global, "global-bin");
4285
4286        assert_eq!(
4287            backend.list_installed_for(&ctx, &version).unwrap(),
4288            vec!["3.6.2"]
4289        );
4290        assert_eq!(
4291            backend.where_install_root_for(&ctx, &version).unwrap(),
4292            Some(isolated.clone())
4293        );
4294        backend.uninstall(&ctx, &version).await.unwrap();
4295        assert!(!isolated.exists());
4296        assert!(global.exists());
4297        let global_version = with_scope(version.clone(), ToolScope::Global);
4298        assert_eq!(
4299            backend
4300                .where_install_root_for(&ctx, &global_version)
4301                .unwrap(),
4302            Some(global.clone())
4303        );
4304        assert!(backend.uninstall_global(&ctx, &global_version).unwrap());
4305        assert!(!global.exists());
4306    }
4307
4308    #[test]
4309    fn explicit_scope_selects_only_that_install_root() {
4310        let temporary = tempfile::tempdir().unwrap();
4311        let ctx = offline_test_ctx(temporary.path());
4312        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
4313        let version = npm_test_version(&backend, "3.6.2");
4314        let isolated =
4315            write_scope_fixture(&backend, &ctx, &version, ToolScope::Project, "isolated-bin");
4316        let global = write_scope_fixture(&backend, &ctx, &version, ToolScope::Global, "global-bin");
4317
4318        let isolated_version = with_scope(version.clone(), ToolScope::Project);
4319        assert_eq!(
4320            backend
4321                .selected_install_root(&ctx, &isolated_version)
4322                .unwrap(),
4323            Some(isolated.clone())
4324        );
4325        assert_eq!(
4326            backend.bin_names(&ctx, &isolated_version).unwrap(),
4327            vec!["isolated-bin"]
4328        );
4329
4330        let global_version = with_scope(version, ToolScope::Global);
4331        assert_eq!(
4332            backend
4333                .selected_install_root(&ctx, &global_version)
4334                .unwrap(),
4335            Some(global.clone())
4336        );
4337        assert_eq!(
4338            backend
4339                .where_install_root_for(&ctx, &global_version)
4340                .unwrap(),
4341            Some(global)
4342        );
4343        assert_eq!(
4344            backend.bin_names(&ctx, &global_version).unwrap(),
4345            vec!["global-bin"]
4346        );
4347    }
4348
4349    #[test]
4350    fn scoped_queries_filter_versions_before_selection() {
4351        let temporary = tempfile::tempdir().unwrap();
4352        let ctx = offline_test_ctx(temporary.path());
4353        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
4354        write_scope_fixture(
4355            &backend,
4356            &ctx,
4357            &npm_test_version(&backend, "3.9.0"),
4358            ToolScope::Project,
4359            "isolated-bin",
4360        );
4361        write_scope_fixture(
4362            &backend,
4363            &ctx,
4364            &npm_test_version(&backend, "3.8.0"),
4365            ToolScope::Global,
4366            "global-bin",
4367        );
4368
4369        let global = with_scope(
4370            npm_test_version(&backend, "identity-selection"),
4371            ToolScope::Global,
4372        );
4373        assert_eq!(
4374            backend.list_installed_for(&ctx, &global).unwrap(),
4375            vec!["3.8.0"]
4376        );
4377
4378        let project = with_scope(
4379            npm_test_version(&backend, "identity-selection"),
4380            ToolScope::Project,
4381        );
4382        assert_eq!(
4383            backend.list_installed_for(&ctx, &project).unwrap(),
4384            vec!["3.9.0"]
4385        );
4386        assert_eq!(
4387            backend
4388                .list_installed_for(&ctx, &npm_test_version(&backend, "identity-selection"))
4389                .unwrap(),
4390            vec!["3.8.0", "3.9.0"]
4391        );
4392    }
4393
4394    #[test]
4395    fn invalid_v2_manifest_scope_fails_closed() {
4396        let temporary = tempfile::tempdir().unwrap();
4397        let ctx = offline_test_ctx(temporary.path());
4398        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
4399        let version = npm_test_version(&backend, "3.6.2");
4400        let locator = backend
4401            .install_locator(&ctx, &version, ToolScope::Project)
4402            .unwrap();
4403        let isolated = locator.install_root();
4404        std::fs::create_dir_all(isolated).unwrap();
4405        let manifest = DynamicToolManifest::from_identity(locator.identity().clone()).unwrap();
4406        let mut value = serde_json::to_value(manifest).unwrap();
4407        value["identity"]["scope"] = serde_json::json!("future-scope");
4408        std::fs::write(
4409            DynamicToolManifest::manifest_path(isolated),
4410            serde_json::to_vec(&value).unwrap(),
4411        )
4412        .unwrap();
4413        std::fs::write(isolated.join(".osdk-complete"), b"").unwrap();
4414
4415        assert!(backend.where_install_root_for(&ctx, &version).is_err());
4416        assert!(backend.list_installed_for(&ctx, &version).is_err());
4417    }
4418
4419    #[test]
4420    fn explicit_scope_does_not_fall_back_to_the_other_root() {
4421        let temporary = tempfile::tempdir().unwrap();
4422        let ctx = offline_test_ctx(temporary.path());
4423        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
4424        let version = npm_test_version(&backend, "3.6.2");
4425        let isolated =
4426            write_scope_fixture(&backend, &ctx, &version, ToolScope::Project, "isolated-bin");
4427        let global_version = with_scope(version.clone(), ToolScope::Global);
4428        assert!(backend
4429            .selected_install_root(&ctx, &global_version)
4430            .unwrap()
4431            .is_none());
4432
4433        std::fs::remove_dir_all(&isolated).unwrap();
4434        write_scope_fixture(&backend, &ctx, &version, ToolScope::Global, "global-bin");
4435        let isolated_version = with_scope(version, ToolScope::Project);
4436        assert!(backend
4437            .selected_install_root(&ctx, &isolated_version)
4438            .unwrap()
4439            .is_none());
4440    }
4441
4442    #[test]
4443    fn project_indirection_wins_over_a_global_direct_key_for_the_same_backend() {
4444        let temporary = tempfile::tempdir().unwrap();
4445        let mut ctx = offline_test_ctx(temporary.path());
4446        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
4447        let global_key = backend.id().to_string();
4448        let project_key = "tool.formatter".to_string();
4449        ctx.config.tools = BTreeMap::from([
4450            (global_key.clone(), "3.6.2".into()),
4451            (project_key.clone(), "npm:prettier@3.6.2".into()),
4452        ]);
4453        ctx.config.tool_origins = BTreeMap::from([
4454            (
4455                global_key.clone(),
4456                ToolConfigOrigin::GlobalConfig(temporary.path().join("config/config.toml")),
4457            ),
4458            (
4459                project_key,
4460                ToolConfigOrigin::ProjectConfig(temporary.path().join("project/osdk.toml")),
4461            ),
4462        ]);
4463        ctx.config
4464            .global_tool_configs
4465            .insert(global_key, crate::config::ToolConfigEntry::legacy("3.6.2"));
4466        let version = npm_test_version(&backend, "3.6.2");
4467        let isolated =
4468            write_scope_fixture(&backend, &ctx, &version, ToolScope::Project, "isolated-bin");
4469        write_scope_fixture(&backend, &ctx, &version, ToolScope::Global, "global-bin");
4470
4471        assert_eq!(
4472            backend.selected_install_root(&ctx, &version).unwrap(),
4473            Some(isolated)
4474        );
4475        assert_eq!(
4476            backend.bin_names(&ctx, &version).unwrap(),
4477            vec!["isolated-bin"]
4478        );
4479    }
4480
4481    #[test]
4482    fn where_query_uses_config_provenance_and_explicit_scope_wins() {
4483        let temporary = tempfile::tempdir().unwrap();
4484        let mut ctx = offline_test_ctx(temporary.path());
4485        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
4486        let key = backend.id().to_string();
4487        ctx.config.tools.insert(key.clone(), "3.6.2".into());
4488        ctx.config.tool_origins.insert(
4489            key.clone(),
4490            ToolConfigOrigin::GlobalConfig(temporary.path().join("config/config.toml")),
4491        );
4492        ctx.config
4493            .global_tool_configs
4494            .insert(key, crate::config::ToolConfigEntry::legacy("3.6.2"));
4495        let version = npm_test_version(&backend, "3.6.2");
4496        let isolated =
4497            write_scope_fixture(&backend, &ctx, &version, ToolScope::Project, "isolated-bin");
4498        let global = write_scope_fixture(&backend, &ctx, &version, ToolScope::Global, "global-bin");
4499
4500        assert_eq!(
4501            backend.where_install_root_for(&ctx, &version).unwrap(),
4502            Some(global)
4503        );
4504
4505        let mut explicit_project = version;
4506        explicit_project.options.insert(
4507            LOCKED_NPM_SCOPE_OPTION.into(),
4508            ToolScope::Project.as_str().into(),
4509        );
4510        assert_eq!(
4511            backend
4512                .where_install_root_for(&ctx, &explicit_project)
4513                .unwrap(),
4514            Some(isolated)
4515        );
4516    }
4517
4518    fn write_legacy_inventory_fixture(root: &Path, id: &str, version: &str, scope: &str) {
4519        let bin = root.join("bin/prettier");
4520        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
4521        std::fs::write(&bin, b"legacy executable").unwrap();
4522        std::fs::write(
4523            root.join(crate::inventory::LEGACY_INVENTORY_FILE),
4524            serde_json::to_vec(&serde_json::json!({
4525                "schema": 2,
4526                "id": id,
4527                "version": version,
4528                "bins": [{"name": "prettier", "path": "bin/prettier"}],
4529                "metadata": {"scope": scope}
4530            }))
4531            .unwrap(),
4532        )
4533        .unwrap();
4534        std::fs::write(root.join(".osdk-complete"), b"").unwrap();
4535    }
4536
4537    #[tokio::test]
4538    async fn legacy_osdk_tool_inventory_is_never_reused_executed_or_uninstalled() {
4539        let temporary = tempfile::tempdir().unwrap();
4540        let ctx = offline_test_ctx(temporary.path());
4541        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
4542        let version = npm_test_version(&backend, "3.6.2");
4543        let legacy_isolated = backend.legacy_isolated_install_root(&ctx, &version.version);
4544        let legacy_global = backend.legacy_global_install_root_path(&ctx, &version.version);
4545        write_legacy_inventory_fixture(&legacy_isolated, backend.id(), &version.version, "project");
4546        write_legacy_inventory_fixture(&legacy_global, backend.id(), &version.version, "global");
4547
4548        let identity = backend
4549            .install_identity(&ctx, &version, ToolScope::Project)
4550            .unwrap();
4551        assert!(!install_matches(
4552            &legacy_isolated,
4553            &identity,
4554            backend.package(),
4555            None,
4556            &BuildPolicy::Deny,
4557        )
4558        .unwrap());
4559        assert!(backend
4560            .where_install_root_for(&ctx, &version)
4561            .unwrap()
4562            .is_none());
4563        assert!(backend.bin_paths(&ctx, &version).unwrap().is_empty());
4564        assert!(backend.bin_names(&ctx, &version).is_err());
4565
4566        let global_version = with_scope(version.clone(), ToolScope::Global);
4567        assert!(backend
4568            .where_install_root_for(&ctx, &global_version)
4569            .unwrap()
4570            .is_none());
4571        assert!(backend.bin_paths(&ctx, &global_version).unwrap().is_empty());
4572        assert!(backend.bin_names(&ctx, &global_version).is_err());
4573        assert!(backend
4574            .list_installed_for_scope(&ctx, ToolScope::Project)
4575            .unwrap()
4576            .is_empty());
4577        assert!(backend
4578            .list_installed_for_scope(&ctx, ToolScope::Global)
4579            .unwrap()
4580            .is_empty());
4581        assert!(backend
4582            .legacy_global_install_root(&ctx, &global_version)
4583            .unwrap()
4584            .is_none());
4585
4586        backend.uninstall(&ctx, &version).await.unwrap();
4587        assert!(!backend.uninstall_global(&ctx, &global_version).unwrap());
4588        assert!(legacy_isolated
4589            .join(crate::inventory::LEGACY_INVENTORY_FILE)
4590            .is_file());
4591        assert!(legacy_global
4592            .join(crate::inventory::LEGACY_INVENTORY_FILE)
4593            .is_file());
4594    }
4595
4596    #[test]
4597    fn package_install_dir_tracks_scope_layout() {
4598        let root = PathBuf::from("/tmp/install/project");
4599        assert_eq!(
4600            package_install_dir(&root, "prettier"),
4601            root.join("node_modules/prettier")
4602        );
4603        assert_eq!(
4604            package_install_dir(&root, "@antfu/ni"),
4605            root.join("node_modules/@antfu/ni")
4606        );
4607    }
4608
4609    #[test]
4610    fn build_policy_is_deny_by_default_and_supports_package_allowlists() {
4611        let version = ToolVersion::new("npm:prettier", "3.0.0");
4612        assert_eq!(
4613            NpmPackageBackend::build_policy(&version).unwrap(),
4614            BuildPolicy::Deny
4615        );
4616
4617        let mut version = version;
4618        version
4619            .options
4620            .insert("allow_builds".into(), "Sharp, esbuild, sharp".into());
4621        assert_eq!(
4622            NpmPackageBackend::build_policy(&version).unwrap(),
4623            BuildPolicy::Packages(vec!["esbuild".into(), "sharp".into()])
4624        );
4625
4626        version.options.insert("allow_builds".into(), "true".into());
4627        assert_eq!(
4628            NpmPackageBackend::build_policy(&version).unwrap(),
4629            BuildPolicy::AllowAll
4630        );
4631    }
4632
4633    #[test]
4634    fn project_manifest_records_only_explicit_build_allowlist() {
4635        let temporary = tempfile::tempdir().unwrap();
4636        NpmPackageBackend::write_project_manifest(
4637            temporary.path(),
4638            None,
4639            &BuildPolicy::Packages(vec!["esbuild".into(), "sharp".into()]),
4640        )
4641        .unwrap();
4642        let manifest: serde_json::Value =
4643            serde_json::from_slice(&std::fs::read(temporary.path().join("package.json")).unwrap())
4644                .unwrap();
4645        assert_eq!(manifest["aube"]["allowBuilds"]["esbuild"], true);
4646        assert_eq!(manifest["aube"]["allowBuilds"]["sharp"], true);
4647        assert!(manifest.get("dependencies").is_none());
4648    }
4649
4650    fn locked_version(backend: &str, package: &str, version: &str, lockfile: &str) -> ToolVersion {
4651        let mut tool = ToolVersion::new(backend, version);
4652        tool.options.insert(
4653            LOCKED_NPM_NODE_VERSION_OPTION.into(),
4654            TEST_NODE_VERSION.into(),
4655        );
4656        tool.options
4657            .insert(LOCKED_NPM_PACKAGE_OPTION.into(), package.into());
4658        tool.options.insert(
4659            LOCKED_NPM_LOCK_FORMAT_OPTION.into(),
4660            AUBE_LOCK_FORMAT.into(),
4661        );
4662        tool.options.insert(
4663            LOCKED_NPM_LOCK_SHA256_OPTION.into(),
4664            pipeline::verify::hash_bytes(lockfile.as_bytes(), pipeline::HashAlgo::Sha256),
4665        );
4666        tool.options
4667            .insert(LOCKED_NPM_LOCKFILE_OPTION.into(), lockfile.into());
4668        tool
4669    }
4670
4671    #[test]
4672    fn locked_graph_validates_identity_format_and_exact_bytes() {
4673        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
4674        let lockfile = "lockfileVersion: '9.0'\n# preserve trailing newline\n";
4675        let version = locked_version("npm:prettier", "prettier", "3.6.2", lockfile);
4676        assert_eq!(
4677            backend.locked_graph(&version).unwrap().unwrap().lockfile,
4678            lockfile
4679        );
4680
4681        let mut mismatched_package = version.clone();
4682        mismatched_package
4683            .options
4684            .insert(LOCKED_NPM_PACKAGE_OPTION.into(), "typescript".into());
4685        assert!(backend
4686            .locked_graph(&mismatched_package)
4687            .unwrap_err()
4688            .to_string()
4689            .contains("identity mismatch"));
4690
4691        let mut unsupported_format = version.clone();
4692        unsupported_format
4693            .options
4694            .insert(LOCKED_NPM_LOCK_FORMAT_OPTION.into(), "pnpm-v8".into());
4695        assert!(backend
4696            .locked_graph(&unsupported_format)
4697            .unwrap_err()
4698            .to_string()
4699            .contains("unsupported locked npm graph format"));
4700
4701        let mut tampered = version;
4702        tampered.options.insert(
4703            LOCKED_NPM_LOCKFILE_OPTION.into(),
4704            "lockfileVersion: '9.0'\n# changed\n".into(),
4705        );
4706        assert!(matches!(
4707            backend.locked_graph(&tampered),
4708            Err(Error::ChecksumMismatch { .. })
4709        ));
4710    }
4711
4712    fn npm_test_lockfile(package: &str, version: &str, integrity: &str) -> String {
4713        format!(
4714            "lockfileVersion: '9.0'\n\nimporters:\n  .:\n    dependencies:\n      '{package}':\n        specifier: {version}\n        version: {version}\n\npackages:\n  '{package}@{version}':\n    resolution: {{integrity: {integrity}}}\n"
4715        )
4716    }
4717
4718    fn write_reusable_install(
4719        backend: &NpmPackageBackend,
4720        ctx: &Ctx,
4721        version: &ToolVersion,
4722        build_policy: &BuildPolicy,
4723        lockfile: &str,
4724    ) -> PathBuf {
4725        let install_root = backend.isolated_install_root_for(ctx, version).unwrap();
4726        let project_dir = install_root.join(PROJECT_DIR);
4727        NpmPackageBackend::write_project_manifest(
4728            &project_dir,
4729            Some((backend.package(), &version.version)),
4730            build_policy,
4731        )
4732        .unwrap();
4733        std::fs::write(project_dir.join(AUBE_LOCKFILE_NAME), lockfile).unwrap();
4734        std::fs::create_dir_all(package_install_dir(&project_dir, backend.package())).unwrap();
4735        std::fs::create_dir_all(project_dir.join("node_modules/.bin")).unwrap();
4736        let bin = project_dir.join("node_modules/.bin/fixture");
4737        std::fs::write(&bin, b"fixture").unwrap();
4738        #[cfg(unix)]
4739        {
4740            use std::os::unix::fs::PermissionsExt;
4741            std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
4742        }
4743        write_node_fixture(ctx, &version.options[LOCKED_NPM_NODE_VERSION_OPTION]);
4744        let graph_identity =
4745            npm_graph_identity(&project_dir, backend.package(), &version.version).unwrap();
4746        let mut manifest = DynamicToolManifest::from_identity(
4747            backend
4748                .install_identity(ctx, version, ToolScope::Project)
4749                .unwrap(),
4750        )
4751        .unwrap();
4752        manifest.bins = vec![DynamicToolBin {
4753            name: "fixture".into(),
4754            path: "project/node_modules/.bin/fixture".into(),
4755        }];
4756        manifest.write_atomic(&install_root).unwrap();
4757        write_npm_receipt(
4758            &install_root,
4759            &NpmInstallReceipt {
4760                schema: NPM_INSTALL_RECEIPT_SCHEMA,
4761                provider: PROVIDER.into(),
4762                package: backend.package().into(),
4763                installer: "aube".into(),
4764                node_version: version.options[LOCKED_NPM_NODE_VERSION_OPTION].clone(),
4765                build_policy: build_policy.identity(),
4766                graph_sha256: Some(graph_identity.sha256),
4767                root_integrity: Some(graph_identity.root_integrity),
4768                root_source: Some(graph_identity.root_source),
4769                native_lock_format: None,
4770                native_lock_sha256: None,
4771            },
4772        )
4773        .unwrap();
4774        std::fs::write(install_root.join(".osdk-complete"), b"").unwrap();
4775        install_root
4776    }
4777
4778    #[test]
4779    fn completed_install_is_reused_only_for_exact_identity() {
4780        let temporary = tempfile::tempdir().unwrap();
4781        let ctx = offline_test_ctx(temporary.path());
4782        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
4783        let lockfile = npm_test_lockfile(
4784            "prettier",
4785            "3.6.2",
4786            "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
4787        );
4788        let version = locked_version("npm:prettier", "prettier", "3.6.2", &lockfile);
4789        let install_root =
4790            write_reusable_install(&backend, &ctx, &version, &BuildPolicy::Deny, &lockfile);
4791        let graph = LockedNpmGraph {
4792            lockfile: &lockfile,
4793        };
4794        let identity = backend
4795            .install_identity(&ctx, &version, ToolScope::Project)
4796            .unwrap();
4797
4798        assert!(install_matches(
4799            &install_root,
4800            &identity,
4801            "prettier",
4802            Some(&graph),
4803            &BuildPolicy::Deny,
4804        )
4805        .unwrap());
4806        for (tool, package, requested_version, node) in [
4807            ("npm:typescript", "prettier", "3.6.2", TEST_NODE_VERSION),
4808            ("npm:prettier", "typescript", "3.6.2", TEST_NODE_VERSION),
4809            ("npm:prettier", "prettier", "3.6.1", TEST_NODE_VERSION),
4810            ("npm:prettier", "prettier", "3.6.2", "20.9.0"),
4811        ] {
4812            let options = BTreeMap::from([(LOCKED_NPM_NODE_VERSION_OPTION.into(), node.into())]);
4813            let mismatched_identity = InstallIdentity::new(
4814                tool,
4815                requested_version,
4816                ctx.platform.to_string(),
4817                InstallScope::Isolated,
4818                &options,
4819                vec![InstallDependency {
4820                    kind: InstallDependencyKind::Runtime,
4821                    id: "node".into(),
4822                    version: node.into(),
4823                    identity: None,
4824                }],
4825                identity.materials.clone(),
4826            )
4827            .unwrap();
4828            assert!(!install_matches(
4829                &install_root,
4830                &mismatched_identity,
4831                package,
4832                Some(&graph),
4833                &BuildPolicy::Deny,
4834            )
4835            .unwrap());
4836        }
4837        assert!(!install_matches(
4838            &install_root,
4839            &identity,
4840            "prettier",
4841            Some(&LockedNpmGraph {
4842                lockfile: "different"
4843            }),
4844            &BuildPolicy::Deny,
4845        )
4846        .unwrap());
4847        let changed_build_identity = InstallIdentity::new(
4848            backend.id(),
4849            &version.version,
4850            ctx.platform.to_string(),
4851            InstallScope::Isolated,
4852            &BTreeMap::from([
4853                (
4854                    LOCKED_NPM_NODE_VERSION_OPTION.into(),
4855                    TEST_NODE_VERSION.into(),
4856                ),
4857                ("allow_builds".into(), "esbuild".into()),
4858            ]),
4859            identity.dependencies.clone(),
4860            identity.materials.clone(),
4861        )
4862        .unwrap();
4863        assert!(!install_matches(
4864            &install_root,
4865            &changed_build_identity,
4866            "prettier",
4867            Some(&graph),
4868            &BuildPolicy::Packages(vec!["esbuild".into()]),
4869        )
4870        .unwrap());
4871
4872        let changed_installer_identity = InstallIdentity::new(
4873            backend.id(),
4874            &version.version,
4875            ctx.platform.to_string(),
4876            InstallScope::Isolated,
4877            &BTreeMap::from([("installer".into(), "aube".into())]),
4878            identity.dependencies.clone(),
4879            identity.materials.clone(),
4880        )
4881        .unwrap();
4882        assert!(!install_matches(
4883            &install_root,
4884            &changed_installer_identity,
4885            "prettier",
4886            Some(&graph),
4887            &BuildPolicy::Deny,
4888        )
4889        .unwrap());
4890        let default_spelling_identity = InstallIdentity::new(
4891            backend.id(),
4892            &version.version,
4893            ctx.platform.to_string(),
4894            InstallScope::Isolated,
4895            &BTreeMap::from([("allow_builds".into(), "false".into())]),
4896            identity.dependencies.clone(),
4897            identity.materials.clone(),
4898        )
4899        .unwrap();
4900        assert!(install_matches(
4901            &install_root,
4902            &default_spelling_identity,
4903            "prettier",
4904            Some(&graph),
4905            &BuildPolicy::Deny,
4906        )
4907        .unwrap());
4908    }
4909
4910    #[test]
4911    fn unlocked_reuse_validates_persisted_graph_and_root_identity() {
4912        let temporary = tempfile::tempdir().unwrap();
4913        let ctx = offline_test_ctx(temporary.path());
4914        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
4915        let lockfile = npm_test_lockfile("prettier", "3.6.2", "sha512-root-integrity");
4916        let version = npm_test_version(&backend, "3.6.2");
4917        let install_root =
4918            write_reusable_install(&backend, &ctx, &version, &BuildPolicy::Deny, &lockfile);
4919        let identity = backend
4920            .install_identity(&ctx, &version, ToolScope::Project)
4921            .unwrap();
4922        assert!(install_matches(
4923            &install_root,
4924            &identity,
4925            "prettier",
4926            None,
4927            &BuildPolicy::Deny,
4928        )
4929        .unwrap());
4930
4931        let tampered = lockfile.replace("sha512-root-integrity", "sha512-tampered");
4932        std::fs::write(
4933            install_root.join(PROJECT_DIR).join(AUBE_LOCKFILE_NAME),
4934            tampered,
4935        )
4936        .unwrap();
4937        assert!(!install_matches(
4938            &install_root,
4939            &identity,
4940            "prettier",
4941            None,
4942            &BuildPolicy::Deny,
4943        )
4944        .unwrap());
4945    }
4946
4947    #[test]
4948    fn graph_identity_handles_scoped_root_with_peer_context() {
4949        let temporary = tempfile::tempdir().unwrap();
4950        let project_dir = temporary.path().join("project");
4951        std::fs::create_dir_all(&project_dir).unwrap();
4952        let lockfile = "lockfileVersion: '9.0'\n\nimporters:\n  .:\n    dependencies:\n      '@scope/tool':\n        specifier: 1.2.3\n        version: 1.2.3(peer@4.5.6)\n\npackages:\n  '@scope/tool@1.2.3':\n    resolution: {integrity: sha512-root, tarball: https://registry.example.test/tool.tgz}\n";
4953        std::fs::write(project_dir.join(AUBE_LOCKFILE_NAME), lockfile).unwrap();
4954
4955        let identity = npm_graph_identity(&project_dir, "@scope/tool", "1.2.3").unwrap();
4956        assert_eq!(identity.root_integrity, "sha512-root");
4957        assert_eq!(
4958            identity.root_source,
4959            "https://registry.example.test/tool.tgz"
4960        );
4961    }
4962
4963    #[test]
4964    fn unlocked_graph_identity_must_match_registry_sri_and_known_tarball() {
4965        let integrity = "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==";
4966        let expected = pipeline::verify::parse_sri(integrity).unwrap();
4967        let tarball = "https://registry.example.test/prettier/-/prettier-3.6.2.tgz";
4968        let identity = NpmGraphIdentity {
4969            sha256: "graph".into(),
4970            root_integrity: integrity.into(),
4971            root_source: tarball.into(),
4972            root_tarball: Some(tarball.into()),
4973        };
4974        assert!(validate_unlocked_graph_identity(
4975            &identity,
4976            &expected,
4977            &[tarball.into()],
4978            "prettier",
4979            "3.6.2"
4980        )
4981        .is_ok());
4982
4983        let mut mismatched_integrity = identity.clone();
4984        mismatched_integrity.root_integrity = "sha256-YWJj".into();
4985        assert!(matches!(
4986            validate_unlocked_graph_identity(
4987                &mismatched_integrity,
4988                &expected,
4989                &[tarball.into()],
4990                "prettier",
4991                "3.6.2"
4992            ),
4993            Err(Error::ChecksumMismatch { .. })
4994        ));
4995
4996        let mut mismatched_source = identity;
4997        mismatched_source.root_tarball = Some("https://evil.example.test/tool.tgz".into());
4998        assert!(validate_unlocked_graph_identity(
4999            &mismatched_source,
5000            &expected,
5001            &[tarball.into()],
5002            "prettier",
5003            "3.6.2"
5004        )
5005        .is_err());
5006    }
5007
5008    #[test]
5009    fn locked_graph_rejects_partial_and_noncanonical_digests() {
5010        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
5011        let mut partial = ToolVersion::new("npm:prettier", "3.6.2");
5012        partial
5013            .options
5014            .insert(LOCKED_NPM_PACKAGE_OPTION.into(), "prettier".into());
5015        assert!(backend.locked_graph(&partial).unwrap().is_none());
5016
5017        partial.options.insert(
5018            LOCKED_NPM_LOCK_FORMAT_OPTION.into(),
5019            AUBE_LOCK_FORMAT.into(),
5020        );
5021        assert!(backend
5022            .locked_graph(&partial)
5023            .unwrap_err()
5024            .to_string()
5025            .contains(LOCKED_NPM_LOCK_SHA256_OPTION));
5026
5027        let lockfile = "lockfileVersion: '9.0'\n";
5028        let mut uppercase = locked_version("npm:prettier", "prettier", "3.6.2", lockfile);
5029        let digest = uppercase.options[LOCKED_NPM_LOCK_SHA256_OPTION].to_uppercase();
5030        uppercase
5031            .options
5032            .insert(LOCKED_NPM_LOCK_SHA256_OPTION.into(), digest);
5033        assert!(backend
5034            .locked_graph(&uppercase)
5035            .unwrap_err()
5036            .to_string()
5037            .contains("invalid SHA-256"));
5038    }
5039
5040    #[test]
5041    fn restoring_locked_project_preserves_lock_bytes_and_exact_manifest_policy() {
5042        let temporary = tempfile::tempdir().unwrap();
5043        let backend = NpmPackageBackend::from_id("npm:@antfu/ni").unwrap();
5044        let lockfile = "lockfileVersion: '9.0'\nimporters: {}\n";
5045        let version = locked_version("npm:@antfu/ni", "@antfu/ni", "0.21.12", lockfile);
5046        let graph = backend.locked_graph(&version).unwrap().unwrap();
5047        backend
5048            .restore_locked_project(
5049                temporary.path(),
5050                &version,
5051                &BuildPolicy::Packages(vec!["esbuild".into()]),
5052                &graph,
5053            )
5054            .unwrap();
5055
5056        let package_json: serde_json::Value =
5057            serde_json::from_slice(&std::fs::read(temporary.path().join("package.json")).unwrap())
5058                .unwrap();
5059        assert_eq!(package_json["dependencies"]["@antfu/ni"], "0.21.12");
5060        assert_eq!(package_json["aube"]["allowBuilds"]["esbuild"], true);
5061        assert_eq!(
5062            std::fs::read(temporary.path().join(AUBE_LOCKFILE_NAME)).unwrap(),
5063            lockfile.as_bytes()
5064        );
5065    }
5066
5067    fn offline_test_ctx(root: &Path) -> Ctx {
5068        let dirs = crate::dirs::Dirs::resolve_from(|key| match key {
5069            "OSDK_DATA_DIR" => Some(root.join("data").display().to_string()),
5070            "OSDK_CACHE_DIR" => Some(root.join("cache").display().to_string()),
5071            "OSDK_CONFIG_DIR" => Some(root.join("config").display().to_string()),
5072            "OSDK_STORE_DIR" => Some(root.join("store").display().to_string()),
5073            _ => None,
5074        })
5075        .unwrap();
5076        let settings = crate::config::Settings {
5077            offline: true,
5078            ..Default::default()
5079        };
5080        Ctx {
5081            cas: std::sync::Arc::new(crate::store::Cas::new(dirs.store.clone())),
5082            dirs,
5083            platform: crate::platform::Platform::current(),
5084            config: crate::config::Config {
5085                settings,
5086                sources: Default::default(),
5087                tools: Default::default(),
5088                tool_configs: Default::default(),
5089                global_tools: Default::default(),
5090                global_tool_configs: Default::default(),
5091                tool_origins: Default::default(),
5092                aliases: Default::default(),
5093                project_config_path: None,
5094            },
5095            client: reqwest::Client::new(),
5096            show_progress: false,
5097        }
5098    }
5099
5100    #[tokio::test]
5101    async fn offline_install_without_graph_fails_before_metadata_or_node_access() {
5102        let temporary = tempfile::tempdir().unwrap();
5103        let ctx = offline_test_ctx(temporary.path());
5104        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
5105        let version = npm_test_version(&backend, "3.6.2");
5106        let install_root = backend.isolated_install_root_for(&ctx, &version).unwrap();
5107
5108        let error = backend
5109            .install(&InstallCtx { ctx: &ctx }, &version)
5110            .await
5111            .unwrap_err();
5112        assert!(error
5113            .to_string()
5114            .contains("without a locked npm dependency graph"));
5115        assert!(!install_root.exists());
5116    }
5117
5118    #[tokio::test]
5119    async fn marker_only_offline_install_is_not_reused() {
5120        let temporary = tempfile::tempdir().unwrap();
5121        let ctx = offline_test_ctx(temporary.path());
5122        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
5123        let version = npm_test_version(&backend, "3.6.2");
5124        let install_root = backend.isolated_install_root_for(&ctx, &version).unwrap();
5125        std::fs::create_dir_all(&install_root).unwrap();
5126        std::fs::write(install_root.join(".osdk-complete"), b"").unwrap();
5127
5128        let error = backend
5129            .install(&InstallCtx { ctx: &ctx }, &version)
5130            .await
5131            .unwrap_err();
5132        assert!(error
5133            .to_string()
5134            .contains("managed Node 20.10.0 has no executable bin directory"));
5135        assert!(!DynamicToolManifest::manifest_path(&install_root).exists());
5136    }
5137
5138    #[test]
5139    fn writes_and_removes_project_npmrc() {
5140        let temporary = tempfile::tempdir().unwrap();
5141        NpmPackageBackend::write_project_npmrc(
5142            temporary.path(),
5143            Some("https://registry.example.test/"),
5144        )
5145        .unwrap();
5146        let npmrc = temporary.path().join(".npmrc");
5147        assert_eq!(
5148            std::fs::read_to_string(&npmrc).unwrap(),
5149            "registry=https://registry.example.test/\n"
5150        );
5151
5152        NpmPackageBackend::write_project_npmrc(temporary.path(), None).unwrap();
5153        assert!(!npmrc.exists());
5154    }
5155
5156    #[cfg(unix)]
5157    #[test]
5158    fn discovers_bins_inside_install_root() {
5159        use std::os::unix::fs::symlink;
5160
5161        let temporary = tempfile::tempdir().unwrap();
5162        let install_root = temporary.path().join("installs/npm/prettier/3.0.0");
5163        let package_dir = install_root.join("project/node_modules/prettier/bin");
5164        let bin_dir = install_root.join("project/node_modules/.bin");
5165        std::fs::create_dir_all(&package_dir).unwrap();
5166        std::fs::create_dir_all(&bin_dir).unwrap();
5167        let script = package_dir.join("prettier.js");
5168        std::fs::write(&script, "#!/usr/bin/env node\n").unwrap();
5169        symlink("../prettier/bin/prettier.js", bin_dir.join("prettier")).unwrap();
5170
5171        let bins = discover_bins(&install_root, &bin_dir).unwrap();
5172        assert_eq!(
5173            bins,
5174            vec![DynamicToolBin {
5175                name: "prettier".into(),
5176                path: "project/node_modules/prettier/bin/prettier.js".into(),
5177            }]
5178        );
5179    }
5180
5181    #[cfg(unix)]
5182    #[test]
5183    fn discovers_project_and_global_bins_through_aliased_install_root() {
5184        use std::os::unix::fs::symlink;
5185
5186        let temporary = tempfile::tempdir().unwrap();
5187        let physical_parent = temporary.path().join("physical");
5188        let aliased_parent = temporary.path().join("aliased");
5189        std::fs::create_dir_all(&physical_parent).unwrap();
5190        symlink(&physical_parent, &aliased_parent).unwrap();
5191
5192        let install_root = aliased_parent.join("install");
5193        let physical_install_root = physical_parent.join("install");
5194        let package_dir = physical_install_root.join("project/node_modules/prettier/bin");
5195        let project_bin_dir = physical_install_root.join("project/node_modules/.bin");
5196        let global_bin_dir = physical_install_root.join("bin");
5197        std::fs::create_dir_all(&package_dir).unwrap();
5198        std::fs::create_dir_all(&project_bin_dir).unwrap();
5199        std::fs::create_dir_all(&global_bin_dir).unwrap();
5200        let script = package_dir.join("prettier.js");
5201        std::fs::write(&script, "#!/usr/bin/env node\n").unwrap();
5202        symlink(
5203            "../prettier/bin/prettier.js",
5204            project_bin_dir.join("prettier"),
5205        )
5206        .unwrap();
5207        symlink(
5208            "../project/node_modules/prettier/bin/prettier.js",
5209            global_bin_dir.join("prettier"),
5210        )
5211        .unwrap();
5212
5213        assert_eq!(
5214            discover_bins(&install_root, &project_bin_dir).unwrap(),
5215            vec![DynamicToolBin {
5216                name: "prettier".into(),
5217                path: "project/node_modules/prettier/bin/prettier.js".into(),
5218            }]
5219        );
5220        assert_eq!(
5221            discover_global_bins(&install_root, &global_bin_dir).unwrap(),
5222            vec![DynamicToolBin {
5223                name: "prettier".into(),
5224                path: "bin/prettier".into(),
5225            }]
5226        );
5227    }
5228
5229    #[cfg(unix)]
5230    #[test]
5231    fn global_inventory_drives_native_bin_paths_after_restart() {
5232        let temporary = tempfile::tempdir().unwrap();
5233        let ctx = offline_test_ctx(temporary.path());
5234        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
5235        let version = with_scope(npm_test_version(&backend, "3.6.2"), ToolScope::Global);
5236        let install_root =
5237            write_scope_fixture(&backend, &ctx, &version, ToolScope::Global, "prettier");
5238        let bin_dir = install_root.join("bin");
5239
5240        assert_eq!(backend.bin_paths(&ctx, &version).unwrap(), vec![bin_dir]);
5241        assert_eq!(
5242            backend.bin_names(&ctx, &version).unwrap(),
5243            vec!["prettier".to_string()]
5244        );
5245    }
5246
5247    #[cfg(unix)]
5248    #[test]
5249    fn rejects_bins_that_escape_install_root() {
5250        use std::os::unix::fs::symlink;
5251
5252        let temporary = tempfile::tempdir().unwrap();
5253        let outside = tempfile::tempdir().unwrap();
5254        let install_root = temporary.path().join("installs/npm/prettier/3.0.0");
5255        let bin_dir = install_root.join("project/node_modules/.bin");
5256        std::fs::create_dir_all(&bin_dir).unwrap();
5257        let outside_script = outside.path().join("prettier.js");
5258        std::fs::write(&outside_script, "#!/usr/bin/env node\n").unwrap();
5259        symlink(&outside_script, bin_dir.join("prettier")).unwrap();
5260
5261        let error = discover_bins(&install_root, &bin_dir).unwrap_err();
5262        assert!(error.to_string().contains("outside install root"));
5263
5264        let global_bin_dir = install_root.join("bin");
5265        std::fs::create_dir_all(&global_bin_dir).unwrap();
5266        symlink(&outside_script, global_bin_dir.join("prettier")).unwrap();
5267        let error = discover_global_bins(&install_root, &global_bin_dir).unwrap_err();
5268        assert!(error.to_string().contains("outside install root"));
5269    }
5270
5271    #[test]
5272    fn manifest_records_install_identity_and_receipt_records_graph_evidence() {
5273        let temporary = tempfile::tempdir().unwrap();
5274        let root = temporary.path();
5275        let data_root = root.join("data");
5276        std::fs::create_dir_all(&data_root).unwrap();
5277        let data_root = std::fs::canonicalize(data_root).unwrap();
5278        let graph_identity = NpmGraphIdentity {
5279            sha256: "graph-sha256".into(),
5280            root_integrity: "sha512-root-integrity".into(),
5281            root_source: "npm-registry".into(),
5282            root_tarball: None,
5283        };
5284        let dirs = crate::dirs::Dirs::resolve_from(|key| match key {
5285            "OSDK_DATA_DIR" => Some(data_root.display().to_string()),
5286            "OSDK_CACHE_DIR" => Some(root.join("cache").display().to_string()),
5287            "OSDK_CONFIG_DIR" => Some(root.join("config").display().to_string()),
5288            _ => None,
5289        })
5290        .unwrap();
5291        let ctx = Ctx {
5292            cas: std::sync::Arc::new(crate::store::Cas::new(dirs.store.clone())),
5293            dirs,
5294            platform: crate::platform::Platform::current(),
5295            config: crate::config::Config {
5296                settings: crate::config::Settings::default(),
5297                sources: Default::default(),
5298                tools: Default::default(),
5299                tool_configs: Default::default(),
5300                global_tools: Default::default(),
5301                global_tool_configs: Default::default(),
5302                tool_origins: Default::default(),
5303                aliases: Default::default(),
5304                project_config_path: None,
5305            },
5306            client: reqwest::Client::new(),
5307            show_progress: false,
5308        };
5309        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
5310        let version = npm_test_version_with_node(&backend, "3.0.0", "24.0.0");
5311        let install_root = backend.isolated_install_root_for(&ctx, &version).unwrap();
5312        let bin_dir = install_root.join("project/node_modules/.bin");
5313        std::fs::create_dir_all(&bin_dir).unwrap();
5314
5315        #[cfg(unix)]
5316        {
5317            use std::os::unix::fs::symlink;
5318            let package_dir = install_root.join("project/node_modules/prettier/bin");
5319            std::fs::create_dir_all(&package_dir).unwrap();
5320            let script = package_dir.join("prettier.js");
5321            std::fs::write(&script, "#!/usr/bin/env node\n").unwrap();
5322            symlink("../prettier/bin/prettier.js", bin_dir.join("prettier")).unwrap();
5323        }
5324        #[cfg(windows)]
5325        {
5326            let package_dir = install_root.join("project/node_modules/prettier/bin");
5327            std::fs::create_dir_all(&package_dir).unwrap();
5328            let script = package_dir.join("prettier.js");
5329            std::fs::write(&script, "console.log('ok')\n").unwrap();
5330            std::fs::write(bin_dir.join("prettier.cmd"), "@echo off\r\n").unwrap();
5331        }
5332
5333        let manifest = backend
5334            .build_manifest(
5335                &ctx,
5336                &version,
5337                &install_root,
5338                &bin_dir,
5339                "24.0.0",
5340                &BuildPolicy::Deny,
5341                &graph_identity,
5342            )
5343            .unwrap();
5344
5345        assert_eq!(
5346            manifest.identity,
5347            backend
5348                .install_identity(&ctx, &version, ToolScope::Project)
5349                .unwrap()
5350        );
5351        assert_eq!(manifest.schema, 1);
5352        let receipt = load_npm_receipt(&install_root).unwrap();
5353        assert_eq!(
5354            receipt,
5355            NpmInstallReceipt {
5356                schema: 1,
5357                provider: PROVIDER.into(),
5358                package: "prettier".into(),
5359                installer: "aube".into(),
5360                node_version: "24.0.0".into(),
5361                build_policy: "deny".into(),
5362                graph_sha256: Some("graph-sha256".into()),
5363                root_integrity: Some("sha512-root-integrity".into()),
5364                root_source: Some("npm-registry".into()),
5365                native_lock_format: None,
5366                native_lock_sha256: None,
5367            }
5368        );
5369    }
5370
5371    #[cfg(unix)]
5372    #[test]
5373    fn finalize_global_install_at_publishes_only_the_supplied_root() {
5374        use std::os::unix::fs::PermissionsExt;
5375
5376        let temporary = tempfile::tempdir().unwrap();
5377        let ctx = offline_test_ctx(temporary.path());
5378        let backend = NpmPackageBackend::from_id("npm:prettier").unwrap();
5379        let version = npm_test_version(&backend, "3.6.2");
5380        let staging = temporary.path().join("stage");
5381        let package = staging.join("project/node_modules/prettier");
5382        let bin_dir = staging.join("bin");
5383        std::fs::create_dir_all(&package).unwrap();
5384        std::fs::create_dir_all(&bin_dir).unwrap();
5385        std::fs::write(
5386            package.join("package.json"),
5387            r#"{"name":"prettier","version":"3.6.2"}"#,
5388        )
5389        .unwrap();
5390        let executable = bin_dir.join("prettier");
5391        std::fs::write(&executable, "#!/bin/sh\nexit 0\n").unwrap();
5392        std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
5393
5394        let manifest = backend
5395            .finalize_global_install_at(
5396                &ctx,
5397                &version,
5398                &staging,
5399                &bin_dir,
5400                "20.10.0",
5401                "aube",
5402                Some(("aube-v9", "digest")),
5403            )
5404            .unwrap();
5405
5406        assert_eq!(manifest.identity.scope, InstallScope::Global);
5407        assert_eq!(
5408            manifest.identity,
5409            backend
5410                .install_identity(&ctx, &version, ToolScope::Global)
5411                .unwrap()
5412        );
5413        assert!(DynamicToolManifest::manifest_path(&staging).is_file());
5414        assert!(staging.join(".osdk-complete").is_file());
5415        assert_eq!(
5416            load_npm_receipt(&staging).unwrap(),
5417            NpmInstallReceipt {
5418                schema: 1,
5419                provider: PROVIDER.into(),
5420                package: "prettier".into(),
5421                installer: "aube".into(),
5422                node_version: TEST_NODE_VERSION.into(),
5423                build_policy: "deny".into(),
5424                graph_sha256: None,
5425                root_integrity: None,
5426                root_source: None,
5427                native_lock_format: Some("aube-v9".into()),
5428                native_lock_sha256: Some("digest".into()),
5429            }
5430        );
5431        assert!(!backend
5432            .global_install_root_for(&ctx, &version)
5433            .unwrap()
5434            .exists());
5435        assert!(!backend
5436            .isolated_install_root_for(&ctx, &version)
5437            .unwrap()
5438            .exists());
5439    }
5440}