//! Project-tree storage for npm packages. //! //! The project root is opened once as a pinned handle; everything below //! descends without following symlinks (see [`super::fs`]). Content is //! verified after publication, so a concurrent verified winner is accepted. use std::collections::BTreeMap; use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use cap_fs_ext::DirExt; use cap_std::fs::Dir; use url::Url; use super::fs; use super::{MAX_NPM_FILE_BYTES, MAX_NPM_TARBALL_COMPRESSED, NpmArtifact, NpmIdentity}; use crate::registry::{NpmPin, Options}; /// Failures reading and publishing verified npm package trees. /// Acquire-time validation lives in the resolver; only verified bytes land here. #[derive(Clone, Debug, thiserror::Error)] pub enum StorageError { #[error("npm vendor registry mismatch")] NpmVendorRegistryMismatch, #[error("npm path escapes its project")] NpmPathEscapesProject, #[error("npm package tree is corrupted: {}", dir.display())] NpmPackageTreeCorrupted { dir: std::path::PathBuf }, #[error("npm package tree is corrupted: {} ({detail})", dir.display())] NpmPackageTreeCorruptedDetail { dir: std::path::PathBuf, detail: String, }, #[error("npm cache entry is not usable")] NpmCacheEntryUnusable, #[error("npm manifest is not usable")] NpmManifestUnusable, #[error("npm vendor manifest is not usable")] NpmVendorManifestUnusable, #[error("npm file is missing: {rel}")] NpmFileMissing { rel: String }, #[error("npm project root is not usable: {source}")] NpmProjectRootUnusable { source: Arc }, #[error("Invalid npm package.json: {source}")] InvalidNpmPackageJson { source: Arc }, #[error("npm manifest is too large")] NpmManifestTooLarge, #[error("npm manifest digest mismatch")] NpmManifestDigestMismatch, #[error("npm manifest changed unexpectedly")] NpmManifestChanged, #[error("npm manifest does not match its locked files")] NpmManifestFilesMismatch, #[error("npm archive changed")] NpmArchiveChanged, #[error("npm path is not usable")] NpmPathUnusable, #[error("{detail}")] NpmStaging { detail: String }, #[error("npm staging collided repeatedly")] NpmStagingCollided, #[error("npm staged file changed: {rel}")] NpmStagedFileChanged { rel: String }, #[error("npm staged file set changed")] NpmStagedFileSetChanged, #[error("npm lock path is not usable")] NpmLockPathUnusable, #[error("npm vendor tree is corrupted: {rel}")] NpmVendorTreeCorrupted { rel: String }, #[error("npm vendor tree is not usable: {source}")] NpmVendorTreeUnusable { source: Arc }, #[error("npm vendor tree is corrupted: {detail}")] NpmVendorTreeCorruptedDetail { detail: String }, #[error("npm tarball is too large: {name}@{version}")] NpmTarballTooLarge { name: String, version: String }, #[error("npm archive changed for {name}@{version}")] NpmArchiveChangedFor { name: String, version: String }, #[error("npm pin is missing its manifest")] NpmPinMissingManifest, #[error("npm backing file changed: {rel}")] NpmBackingFileChanged { rel: String }, #[error("npm tarball has a duplicate file: {path}")] NpmTarballDuplicate { path: String }, #[error("Invalid npm manifest")] InvalidNpmManifest, #[error("npm file integrity mismatch: {rel}")] NpmFileIntegrityMismatch { rel: String }, #[error("npm manifest is missing")] NpmManifestMissing, #[error("npm pin is missing package.json")] NpmPinMissingPackageJson, #[error(transparent)] Npm(Box), #[error(transparent)] Io(std::sync::Arc), #[error(transparent)] Json(std::sync::Arc), } impl From for StorageError { fn from(error: std::io::Error) -> Self { Self::Io(std::sync::Arc::new(error)) } } impl From for StorageError { fn from(error: serde_json::Error) -> Self { Self::Json(std::sync::Arc::new(error)) } } impl From for StorageError { fn from(error: super::NpmError) -> Self { Self::Npm(Box::new(error)) } } fn npm_root(options: &Options) -> PathBuf { options.project_root.join(".avril").join("npm") } fn registry_tag(registry: &Url) -> String { crate::registry::artifact_key(registry.as_str()) } pub(crate) fn npm_package_dir( options: &Options, instance: &str, name: &str, version: &str, ) -> PathBuf { npm_root(options) .join(instance) .join(registry_tag(&options.npm_registry)) .join(name) .join(version) } /// Dependency-free instances share the acquisition tree; other instances /// retain their own paths, with hard-linked content where supported. pub(crate) fn npm_disk_instance<'a>(id: &'a str, package: &str) -> &'a str { if id == package { super::NPM_DEFAULT_INSTANCE } else { id } } fn packument_cache_path(options: &Options, name: &str) -> PathBuf { let safe = name.replace('/', "%2f"); npm_root(options) .join(".metadata") .join("packuments") .join(registry_tag(&options.npm_registry)) .join(format!("{safe}.json")) } fn tarball_cache_path(options: &Options, tarball: &str) -> PathBuf { npm_root(options) .join(".artifacts") .join("tarballs") .join(crate::registry::artifact_key(tarball)) } pub(crate) fn npm_vendor_relative( options: &Options, instance: &str, registry: &Url, name: &str, version: &str, ) -> Result { super::validate_npm_instance_id(instance)?; super::validate_npm_name(name)?; super::validate_npm_version(version)?; if !super::same_origin(registry, &options.npm_registry) { return Err(StorageError::NpmVendorRegistryMismatch); } Ok(PathBuf::from("npm") .join(instance) .join(registry_tag(registry)) .join(name) .join(version)) } fn relative_path(anchor: &Path, path: &Path) -> Result { let rel = path .strip_prefix(anchor) .map_err(|_| StorageError::NpmPathEscapesProject)?; let mut out = PathBuf::new(); for part in rel.components() { let Component::Normal(name) = part else { return Err(StorageError::NpmPathEscapesProject); }; out.push(name); } Ok(out) } fn open_project(options: &Options) -> Result { fs::open_root(&options.project_root) .map_err(|source| StorageError::NpmProjectRootUnusable { source: Arc::new(source) }) } // No-follow open, fstat-confirmed regular file, bounded read. fn read_file_in_dir( directory: &Dir, name: &str, rel: &str, max_bytes: usize, ) -> Result, StorageError> { let file = fs::open_file(directory, name) .map_err(|_| StorageError::NpmFileMissing { rel: rel.to_owned() })?; Ok(fs::read_bounded(&file, max_bytes as u64, rel)?) } pub(crate) fn read_project_file( project_root: &Path, dir: &Path, rel: &str, max_bytes: usize, ) -> Result, StorageError> { let root = fs::open_root(project_root)?; super::validate_npm_relpath(rel)?; let dir_rel = relative_path(project_root, dir)?; let package = fs::walk(&root, &dir_rel)?; let (parent, name) = rel.rsplit_once('/').unwrap_or(("", rel)); let directory = fs::walk(&package, Path::new(parent)) .map_err(|_| StorageError::NpmFileMissing { rel: rel.to_owned() })?; read_file_in_dir(&directory, name, rel, max_bytes) } // Verifies every pinned file with one bounded read each and returns the // package document parsed from those same bytes. fn verify_and_load( package: &Dir, files: &BTreeMap, ) -> Result { let mut package_bytes: Option> = None; let mut parent_cache: Option<(&str, Dir)> = None; for (rel, expected) in files { super::validate_npm_relpath(rel)?; let (parent, name) = rel.rsplit_once('/').unwrap_or(("", rel)); let parent_path = Path::new(parent); if parent_cache .as_ref() .is_none_or(|(cached, _)| Path::new(cached) != parent_path) { let directory = fs::walk(package, parent_path) .map_err(|_| StorageError::NpmFileMissing { rel: rel.to_owned() })?; parent_cache = Some((parent, directory)); } let directory = &parent_cache.as_ref().unwrap().1; let bytes = read_file_in_dir(directory, name, rel, MAX_NPM_FILE_BYTES)?; if crate::registry::checksum(&bytes) != *expected { return Err(StorageError::NpmFileIntegrityMismatch { rel: rel.to_owned() }); } if *rel == "package.json" { package_bytes = Some(bytes); } } let package_bytes = package_bytes.ok_or(StorageError::NpmPinMissingPackageJson)?; serde_json::from_slice(&package_bytes) .map_err(|source| StorageError::InvalidNpmPackageJson { source: Arc::new(source) }) } pub(crate) fn verify_and_load_npm_dir( project_root: &Path, dir: &Path, files: &BTreeMap, ) -> Result { let root = fs::open_root(project_root)?; let dir_rel = relative_path(project_root, dir)?; let package = fs::walk(&root, &dir_rel)?; verify_and_load(&package, files) } #[derive(Clone, PartialEq, Eq)] pub(crate) enum NpmDirState { Absent, Verified(serde_json::Value), } pub(crate) fn verify_npm_metadata( project_root: &Path, dir: &Path, files: &BTreeMap, ) -> Result, StorageError> { let root = fs::open_root(project_root)?; let dir_rel = relative_path(project_root, dir)?; let package = match fs::walk(&root, &dir_rel) { Ok(package) => package, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(_) => return Err(StorageError::NpmPackageTreeCorrupted { dir: dir.to_owned() }), }; let expected = files .get("package.json") .ok_or(StorageError::NpmPinMissingPackageJson)?; let result = (|| { let bytes = read_file_in_dir(&package, "package.json", "package.json", MAX_NPM_FILE_BYTES)?; if crate::registry::checksum(&bytes) != *expected { return Err(StorageError::NpmFileIntegrityMismatch { rel: "package.json".into() }); } serde_json::from_slice(&bytes) .map_err(|source| StorageError::InvalidNpmPackageJson { source: Arc::new(source) }) })(); result .map(Some) .map_err(|error| StorageError::NpmPackageTreeCorruptedDetail { dir: dir.to_owned(), detail: error.to_string(), }) } pub(crate) fn npm_dir_state( project_root: &Path, dir: &Path, files: &BTreeMap, ) -> Result { let root = fs::open_root(project_root)?; let dir_rel = relative_path(project_root, dir)?; let package = match fs::walk(&root, &dir_rel) { Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return Ok(NpmDirState::Absent); } Err(_) => { return Err(StorageError::NpmPackageTreeCorrupted { dir: dir.to_owned() }); } Ok(package) => package, }; match verify_and_load(&package, files) { Ok(package) => Ok(NpmDirState::Verified(package)), Err(error) => Err(StorageError::NpmPackageTreeCorruptedDetail { dir: dir.to_owned(), detail: error.to_string(), }), } } /// Readiness is not authentication: contents are verified when consumed. Missing /// storage is restored during async preparation, never by synchronous require. pub(crate) fn npm_instance_ready( options: &Options, dir: &Path, backing: &Path, pin: &NpmPin, ) -> Result { if pin.is_legacy() && !options.frozen { return Ok(false); } let root = open_project(options)?; if pin.files.is_none() { let digest = pin .manifest .as_ref() .ok_or(StorageError::NpmPinMissingManifest)?; let rel = manifest_rel(digest)?; let parent = match fs::walk(&root, rel.parent().unwrap()) { Ok(parent) => parent, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), Err(_) => return Err(StorageError::NpmManifestUnusable), }; match fs::open_file(&parent, rel.file_name().unwrap().to_str().unwrap()) { Ok(file) if file.metadata()?.is_file() => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), _ => return Err(StorageError::NpmManifestUnusable), } } for path in std::iter::once(dir).chain((dir != backing).then_some(backing)) { match fs::walk(&root, &relative_path(&options.project_root, path)?) { Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), Err(_) => return Err(StorageError::NpmPackageTreeCorrupted { dir: path.to_owned() }), } } Ok(true) } pub(crate) fn ensure_npm_instance( options: &Options, backing: &NpmArtifact, instance: &str, ) -> Result { super::validate_npm_instance_id(instance)?; let identity = &backing.identity; let files = &backing.files; let dir = npm_package_dir(options, instance, &identity.name, &identity.version); // Acquisition already authenticated the metadata at this exact path. if dir == backing.dir { return Ok(dir); } if verify_npm_metadata(&options.project_root, &dir, files)?.is_some() { return Ok(dir); } let _guard = lock_package(options, &identity.key())?; if verify_npm_metadata(&options.project_root, &dir, files)?.is_some() { return Ok(dir); } let root = open_project(options)?; let source_rel = relative_path(&options.project_root, &backing.dir)?; let destination_rel = relative_path(&options.project_root, &dir)?; let source = fs::walk(&root, &source_rel)?; publish_dir(&root, &destination_rel, |staging| { for (rel, expected) in files { link_or_copy_file(&source, staging, rel, expected)?; } Ok(()) })?; verify_and_load_npm_dir(&options.project_root, &dir, files)?; Ok(dir) } fn link_or_copy_file( source: &Dir, staging: &Dir, rel: &str, expected: &str, ) -> Result<(), StorageError> { super::validate_npm_relpath(rel)?; let (parent, name) = rel.rsplit_once('/').unwrap_or(("", rel)); let source_parent = fs::walk(source, Path::new(parent))?; let bytes = read_file_in_dir(&source_parent, name, rel, MAX_NPM_FILE_BYTES)?; if crate::registry::checksum(&bytes) != expected { return Err(StorageError::NpmFileIntegrityMismatch { rel: rel.to_owned() }); } let mut destination_parent = staging.try_clone()?; for part in parent.split('/').filter(|part| !part.is_empty()) { destination_parent = match destination_parent.open_dir_nofollow(part) { Ok(child) => child, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { destination_parent.create_dir(part)?; destination_parent.open_dir_nofollow(part)? } Err(error) => return Err(StorageError::NpmStaging { detail: error.to_string() }), }; } match source_parent.hard_link(name, &destination_parent, name) { Ok(()) => Ok(()), Err(_) => stage_file(staging, rel, &bytes, expected), } } // Missing entries read as absent; anything else unusable fails. fn read_cache(root: &Dir, rel: &Path, max_bytes: usize) -> Result>, StorageError> { let (parent_rel, name) = match rel.parent().zip(rel.file_name()) { Some((parent, name)) => (parent.to_owned(), name.to_owned()), None => return Err(StorageError::NpmCacheEntryUnusable), }; let name = name.to_str().ok_or(StorageError::NpmCacheEntryUnusable)?; let parent = match fs::walk(root, &parent_rel) { Ok(parent) => parent, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(_) => return Err(StorageError::NpmCacheEntryUnusable), }; let file = match fs::open_file(&parent, name) { Ok(file) => file, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(_) => return Err(StorageError::NpmCacheEntryUnusable), }; match fs::read_bounded(&file, max_bytes as u64, name) { Ok(bytes) => Ok(Some(bytes)), Err(_) => Err(StorageError::NpmCacheEntryUnusable), } } pub(crate) fn read_packument_cache( options: &Options, name: &str, max_bytes: usize, ) -> Result>, StorageError> { let root = open_project(options)?; let rel = relative_path(&options.project_root, &packument_cache_path(options, name))?; read_cache(&root, &rel, max_bytes) } pub(crate) fn read_tarball_cache( options: &Options, tarball: &str, max_bytes: usize, ) -> Result>, StorageError> { let root = open_project(options)?; let rel = relative_path(&options.project_root, &tarball_cache_path(options, tarball))?; read_cache(&root, &rel, max_bytes) } fn write_cache(root: &Dir, rel: &Path, bytes: &[u8]) -> Result<(), StorageError> { let (parent_rel, name) = match rel.parent().zip(rel.file_name()) { Some((parent, name)) => (parent.to_owned(), name.to_owned()), None => return Err(StorageError::NpmCacheEntryUnusable), }; let name = name .to_str() .ok_or(StorageError::NpmCacheEntryUnusable)? .to_owned(); let parent = fs::mkdirs(root, &parent_rel)?; Ok(fs::write_atomic(&parent, &name, bytes)?) } pub(crate) fn write_vendor_file( destination: &Path, relative: &Path, bytes: &[u8], ) -> Result<(), StorageError> { std::fs::create_dir_all(destination)?; let root = fs::open_root(destination)?; write_cache(&root, relative, bytes) } pub(crate) fn write_packument_cache( options: &Options, name: &str, bytes: &[u8], ) -> Result<(), StorageError> { let root = open_project(options)?; let rel = relative_path(&options.project_root, &packument_cache_path(options, name))?; write_cache(&root, &rel, bytes) } pub(crate) fn write_tarball_cache( options: &Options, tarball: &str, bytes: &[u8], ) -> Result<(), StorageError> { let root = open_project(options)?; let rel = relative_path(&options.project_root, &tarball_cache_path(options, tarball))?; write_cache(&root, &rel, bytes) } const MAX_NPM_MANIFEST_BYTES: usize = 8 * 1024 * 1024; // Hash exactly the canonical bytes we store; peer instances share this manifest. pub(crate) fn manifest_digest(files: &BTreeMap) -> Result { Ok(crate::registry::checksum(&manifest_bytes(files)?)) } fn manifest_bytes(files: &BTreeMap) -> Result, StorageError> { let bytes = serde_json::to_vec(files)?; if bytes.len() > MAX_NPM_MANIFEST_BYTES { return Err(StorageError::NpmManifestTooLarge); } Ok(bytes) } fn manifest_rel(digest: &str) -> Result { Ok(PathBuf::from(".avril") .join("npm") .join(".manifests") .join(super::manifest_file_name(digest)?)) } fn vendor_manifest_rel(digest: &str) -> Result { Ok(PathBuf::from("npm") .join(".manifests") .join(super::manifest_file_name(digest)?)) } fn parse_manifest_map( bytes: &[u8], digest: &str, ) -> Result, StorageError> { if crate::registry::checksum(bytes) != digest { return Err(StorageError::NpmManifestDigestMismatch); } let map: BTreeMap = serde_json::from_slice(bytes).map_err(|_| StorageError::InvalidNpmManifest)?; super::validate_files_map(&map)?; Ok(map) } /// Load and authenticate the project sidecar. Missing reads as absent; /// anything else unusable (symlink, FIFO, corrupt bytes, digest mismatch) /// fails closed with no repair. pub(crate) fn load_manifest_map( options: &Options, digest: &str, ) -> Result>, StorageError> { let root = open_project(options)?; let rel = manifest_rel(digest)?; match read_cache(&root, &rel, MAX_NPM_MANIFEST_BYTES) { Ok(None) => Ok(None), Ok(Some(bytes)) => parse_manifest_map(&bytes, digest).map(Some), Err(StorageError::NpmCacheEntryUnusable) => Err(StorageError::NpmManifestUnusable), Err(error) => Err(error), } } /// Load and authenticate the vendor mirror sidecar. Unconfigured vendor /// roots and absent sidecars read as missing; anything else fails. pub(crate) fn load_vendor_map( options: &Options, digest: &str, ) -> Result>, StorageError> { let Some(vendor) = open_vendor(options)? else { return Ok(None); }; let rel = vendor_manifest_rel(digest)?; match read_cache(&vendor, &rel, MAX_NPM_MANIFEST_BYTES) { Ok(None) => Ok(None), Ok(Some(bytes)) => parse_manifest_map(&bytes, digest).map(Some), Err(StorageError::NpmCacheEntryUnusable) => Err(StorageError::NpmVendorManifestUnusable), Err(error) => Err(error), } } /// Publish the sidecar for an authenticated map, creating it only when /// absent; existing divergent bytes fail instead of being repaired. pub(crate) fn ensure_manifest_sidecar( options: &Options, files: &BTreeMap, ) -> Result { super::validate_files_map(files)?; let bytes = manifest_bytes(files)?; let digest = crate::registry::checksum(&bytes); let root = open_project(options)?; let rel = manifest_rel(&digest)?; match read_cache(&root, &rel, MAX_NPM_MANIFEST_BYTES) { Ok(None) => { write_cache(&root, &rel, &bytes)?; Ok(digest) } Ok(Some(existing)) => { if crate::registry::checksum(&existing) != digest { return Err(StorageError::NpmManifestChanged); } Ok(digest) } Err(StorageError::NpmCacheEntryUnusable) => Err(StorageError::NpmManifestUnusable), Err(error) => Err(error), } } /// Authenticated file map for vendoring: legacy inline maps directly, /// compact pins through the project sidecar, with a tarball-cache /// recovery that re-checks the digest. Never lists the tree. pub(crate) fn load_vendor_source_files( options: &Options, identity_key: &str, pin: &NpmPin, ) -> Result, StorageError> { if let Some(files) = &pin.files { if let Some(manifest) = &pin.manifest && manifest_digest(files)? != *manifest { return Err(StorageError::NpmManifestFilesMismatch); } super::validate_files_map(files)?; return Ok(files.clone()); } let manifest = pin .manifest .as_ref() .ok_or(StorageError::NpmPinMissingManifest)?; if let Some(map) = load_manifest_map(options, manifest)? { return Ok(map); } let bytes = read_tarball_cache(options, &pin.tarball, MAX_NPM_TARBALL_COMPRESSED)? .ok_or(StorageError::NpmManifestMissing)?; super::verify_sri(&bytes, &pin.integrity)?; let (_, name, version) = super::parse_npm_key(identity_key)?; let data = super::collect_npm_files(&bytes)?; let (derived, _) = super::bind_npm_files(&data, &name, &version)?; if manifest_digest(&derived)? != *manifest { return Err(StorageError::NpmArchiveChanged); } ensure_manifest_sidecar(options, &derived)?; Ok(derived) } /// Canonical sidecar bytes for the vendor mirror. pub(crate) fn write_vendor_manifest( destination: &Path, files: &BTreeMap, ) -> Result<(), StorageError> { let bytes = manifest_bytes(files)?; let digest = crate::registry::checksum(&bytes); write_vendor_file(destination, &vendor_manifest_rel(&digest)?, &bytes) } // Stage under a unique name in the central staging area, then rename over // the destination. The rename is the only destination mutation, so a // concurrent verified winner stays complete. A swapped symlink at the // destination fails verification instead of being trusted. fn publish_dir( root: &Dir, dest_rel: &Path, fill: impl FnOnce(&Dir) -> Result<(), StorageError>, ) -> Result<(), StorageError> { let (parent_rel, name) = match dest_rel.parent().zip(dest_rel.file_name()) { Some((parent, name)) => (parent.to_owned(), name.to_owned()), None => return Err(StorageError::NpmPathUnusable), }; let name = name .to_str() .ok_or(StorageError::NpmPathUnusable)? .to_owned(); let parent = fs::mkdirs(root, &parent_rel)?; let staging_root = fs::mkdirs(root, Path::new(".avril/npm/.tmp"))?; let staging = (|| -> Result { for _ in 0..8 { let candidate = fs::unique_tmp(); match staging_root.create_dir(&candidate) { Ok(()) => return Ok(candidate), Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(error) => return Err(StorageError::NpmStaging { detail: error.to_string() }), } } Err(StorageError::NpmStagingCollided) })()?; let staging_dir = staging_root.open_dir_nofollow(&staging)?; if let Err(error) = fill(&staging_dir) { fs::remove_all(&staging_root, &staging); return Err(error); } match staging_root.rename(&staging, &parent, &name) { Ok(()) => Ok(()), Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists || error.kind() == std::io::ErrorKind::DirectoryNotEmpty => { fs::remove_all(&staging_root, &staging); Ok(()) } Err(error) => { fs::remove_all(&staging_root, &staging); Err(error.into()) } } } fn stage_file(staging: &Dir, rel: &str, bytes: &[u8], expected: &str) -> Result<(), StorageError> { super::validate_npm_relpath(rel)?; if crate::registry::checksum(bytes) != expected { return Err(StorageError::NpmStagedFileChanged { rel: rel.to_owned() }); } let mut dir = staging.try_clone()?; let mut parts: Vec<&str> = rel.split('/').collect(); let name = parts.pop().unwrap_or_default(); for part in parts { dir = match dir.open_dir_nofollow(part) { Ok(child) => child, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { dir.create_dir(part)?; dir.open_dir_nofollow(part)? } Err(error) => return Err(StorageError::NpmStaging { detail: error.to_string() }), }; } if fs::open_file(&dir, name).is_ok() { return Err(StorageError::NpmTarballDuplicate { path: rel.to_owned() }); } let file = fs::write_new(&dir, name, bytes)?; fs::set_readable(&file)?; file.sync_all()?; Ok(()) } fn stage_data_files( staging: &Dir, data: &BTreeMap>, files: &BTreeMap, ) -> Result<(), StorageError> { if data.len() != files.len() { return Err(StorageError::NpmStagedFileSetChanged); } for (rel, bytes) in data { let expected = files .get(rel.as_str()) .ok_or(StorageError::NpmStagedFileChanged { rel: rel.to_owned() })?; stage_file(staging, rel, bytes, expected)?; } Ok(()) } // Per-package guard file locked on its opened inode. Verified hits return // before this runs, so reads take no lock. fn lock_package(options: &Options, identity_key: &str) -> Result { let root = open_project(options)?; let guard = npm_root(options).join(".locks").join(format!( "npm-{}", crate::registry::artifact_key(identity_key) )); let rel = relative_path(&options.project_root, &guard)?; let (parent_rel, name) = match rel.parent().zip(rel.file_name()) { Some((parent, name)) => (parent.to_owned(), name.to_owned()), None => return Err(StorageError::NpmLockPathUnusable), }; let name = name.to_str().ok_or(StorageError::NpmLockPathUnusable)?; let parent = fs::mkdirs(&root, &parent_rel)?; Ok(fs::lock_file(&parent, name)?) } /// Create `.avril/.gitignore` containing `*` when absent, leaving any /// existing file exactly alone. pub(crate) fn ensure_npm_gitignore(project_root: &Path) -> Result<(), StorageError> { let root = fs::open_root(project_root)?; let avril = fs::mkdirs(&root, Path::new(".avril"))?; match fs::open_file(&avril, ".gitignore") { Ok(_) => Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => { match fs::write_new(&avril, ".gitignore", b"*\n") { Ok(file) => Ok(file.sync_all()?), Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), Err(error) => Err(error.into()), } } Err(error) => Err(error.into()), } } pub(crate) fn read_npm_project_file( options: &Options, dir: &Path, rel: &str, ) -> Result, StorageError> { read_project_file(&options.project_root, dir, rel, MAX_NPM_FILE_BYTES) } /// Registry coordinates of one published archive, as recorded by a lockfile. pub(crate) struct NpmArchive<'a> { pub registry: &'a Url, pub name: &'a str, pub version: &'a str, pub tarball: &'a str, pub integrity: &'a str, } fn build_artifact( archive: NpmArchive<'_>, files: BTreeMap, dir: PathBuf, package: serde_json::Value, ) -> NpmArtifact { NpmArtifact { identity: NpmIdentity { registry: archive.registry.clone(), name: archive.name.to_owned(), version: archive.version.to_owned(), }, tarball: archive.tarball.to_owned(), integrity: archive.integrity.to_owned(), dir, files, package, } } fn read_vendor_file( vendor: &Dir, vendor_rel: &Path, rel: &str, expected: &str, ) -> Result, StorageError> { super::validate_npm_relpath(rel)?; let mut dir_rel = vendor_rel.to_owned(); let mut parts: Vec<&str> = rel.split('/').collect(); let name = parts.pop().unwrap_or_default(); for part in parts { dir_rel.push(part); } let parent = fs::walk(vendor, &dir_rel) .map_err(|_| StorageError::NpmVendorTreeCorrupted { rel: rel.to_owned() })?; let file = fs::open_file(&parent, name) .map_err(|_| StorageError::NpmVendorTreeCorrupted { rel: rel.to_owned() })?; let bytes = fs::read_bounded(&file, MAX_NPM_FILE_BYTES as u64, rel) .map_err(|_| StorageError::NpmVendorTreeCorrupted { rel: rel.to_owned() })?; if crate::registry::checksum(&bytes) != expected { return Err(StorageError::NpmVendorTreeCorrupted { rel: rel.to_owned() }); } Ok(bytes) } fn verify_vendor_dir( vendor: &Dir, vendor_rel: &Path, files: &BTreeMap, ) -> Result { let mut package_bytes: Option> = None; for (rel, expected) in files { let bytes = read_vendor_file(vendor, vendor_rel, rel, expected)?; if *rel == "package.json" { package_bytes = Some(bytes); } } let package_bytes = package_bytes.ok_or(StorageError::NpmPinMissingPackageJson)?; serde_json::from_slice(&package_bytes) .map_err(|source| StorageError::InvalidNpmPackageJson { source: Arc::new(source) }) } // Absent or unconfigured vendor roots read as missing so acquisition falls // through; any other failure (symlinked parent, unreadable tree) fails. fn open_vendor(options: &Options) -> Result, StorageError> { let Some(vendor) = options.vendor.as_ref() else { return Ok(None); }; match fs::open_root(vendor) { Ok(root) => Ok(Some(root)), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(error) => Err(StorageError::NpmVendorTreeUnusable { source: Arc::new(error) }), } } pub(crate) fn seed_npm_from_vendor( options: &Options, npm_registry: &Url, instance: &str, name: &str, version: &str, pin: &NpmPin, files: &BTreeMap, ) -> Result, StorageError> { let Some(vendor) = open_vendor(options)? else { return Ok(None); }; let vendor_rel: PathBuf = [ "npm", instance, ®istry_tag(&options.npm_registry), name, version, ] .iter() .collect(); // A missing vendor subtree falls through to verified sources; anything // present but unreadable fails below. if let Err(error) = fs::walk(&vendor, &vendor_rel) { if error.kind() == std::io::ErrorKind::NotFound { return Ok(None); } return Err(StorageError::NpmVendorTreeCorruptedDetail { detail: error.to_string() }); } let package = verify_vendor_dir(&vendor, &vendor_rel, files)?; let root = open_project(options)?; let dir = npm_package_dir(options, instance, name, version); let dir_rel = relative_path(&options.project_root, &dir)?; publish_dir(&root, &dir_rel, |staging| { for (rel, expected_hash) in files { let bytes = read_vendor_file(&vendor, &vendor_rel, rel, expected_hash)?; stage_file(staging, rel, &bytes, expected_hash)?; } Ok(()) })?; let verified = verify_and_load_npm_dir(&options.project_root, &dir, files)?; debug_assert_eq!(verified, package); ensure_manifest_sidecar(options, files)?; ensure_npm_gitignore(&options.project_root)?; Ok(Some(build_artifact( NpmArchive { registry: npm_registry, name, version, tarball: &pin.tarball, integrity: &pin.integrity, }, files.clone(), dir, verified, ))) } // Materialize one verified tarball under a per-package lock. Read-only // hits return before any locking or writing. pub(crate) fn materialize_npm( options: &Options, authority: &Option, archive: NpmArchive<'_>, instance: &str, bytes: Vec, expected: Option<&NpmPin>, ) -> Result { let NpmArchive { registry: npm_registry, name, version, tarball, integrity, } = archive; if bytes.len() > MAX_NPM_TARBALL_COMPRESSED { return Err(StorageError::NpmTarballTooLarge { name: name.to_owned(), version: version.to_owned(), }); } super::verify_sri(&bytes, integrity)?; let data = super::collect_npm_files(&bytes)?; let (files, _) = super::bind_npm_files(&data, name, version)?; if let Some(expected) = expected { if expected.tarball != tarball || expected.integrity != integrity { return Err(StorageError::NpmArchiveChangedFor { name: name.to_owned(), version: version.to_owned(), }); } match (&expected.manifest, &expected.files) { (Some(manifest), _) => { if manifest_digest(&files)? != *manifest { return Err(StorageError::NpmArchiveChangedFor { name: name.to_owned(), version: version.to_owned(), }); } } (None, Some(locked)) => { if *locked != files { return Err(StorageError::NpmArchiveChangedFor { name: name.to_owned(), version: version.to_owned(), }); } } (None, None) => { return Err(StorageError::NpmPinMissingManifest); } } } super::check_npm_exec(authority.as_ref(), npm_registry, name, version, &files)?; let dir = npm_package_dir(options, instance, name, version); if npm_dir_state(&options.project_root, &dir, &files)? == NpmDirState::Absent { let _guard = lock_package( options, &format!("{}|{name}|{version}", npm_registry.as_str()), )?; if npm_dir_state(&options.project_root, &dir, &files)? == NpmDirState::Absent { let root = open_project(options)?; let dir_rel = relative_path(&options.project_root, &dir)?; publish_dir(&root, &dir_rel, |staging| { stage_data_files(staging, &data, &files) })?; ensure_npm_gitignore(&options.project_root)?; } } let package = verify_and_load_npm_dir(&options.project_root, &dir, &files)?; ensure_manifest_sidecar(options, &files)?; Ok(NpmArtifact { identity: NpmIdentity { registry: npm_registry.clone(), name: name.to_owned(), version: version.to_owned(), }, tarball: tarball.to_owned(), integrity: integrity.to_owned(), dir, files, package, }) }