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#[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#[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 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 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 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 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 pub fn where_install_root_for(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Option<PathBuf>> {
341 self.selected_install_root(ctx, tv)
342 }
343
344 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 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 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 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 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 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 pub fn remove_legacy_global_install(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<bool> {
441 let _ = (ctx, tv);
442 Ok(false)
443 }
444
445 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 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 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 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 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 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 #[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 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
2797pub 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
2806pub 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), ¤t)?;
2908 Ok(generation_dir.join(PROJECT_NPM_BIN_BIN_DIR))
2909}
2910
2911pub 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 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>(¤t_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(¤t.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(¤t.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}