char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somnpm: restore dep/peer metadata from lockfile instead of recomputing214dd78

main
36.9 KiB1051 linesraw
1//! Project-tree storage for npm packages.
2//!
3//! The project root is opened once as a pinned handle; everything below
4//! descends without following symlinks (see [`super::fs`]). Content is
5//! verified after publication, so a concurrent verified winner is accepted.
6
7use std::collections::BTreeMap;
8use std::path::{Component, Path, PathBuf};
9use std::sync::Arc;
10
11use cap_fs_ext::DirExt;
12use cap_std::fs::Dir;
13use url::Url;
14
15use super::fs;
16use super::{MAX_NPM_FILE_BYTES, MAX_NPM_TARBALL_COMPRESSED, NpmArtifact, NpmIdentity};
17use crate::registry::{NpmPin, Options};
18
19/// Failures reading and publishing verified npm package trees.
20/// Acquire-time validation lives in the resolver; only verified bytes land here.
21#[derive(Clone, Debug, thiserror::Error)]
22pub enum StorageError {
23    #[error("npm vendor registry mismatch")]
24    NpmVendorRegistryMismatch,
25    #[error("npm path escapes its project")]
26    NpmPathEscapesProject,
27    #[error("npm package tree is corrupted: {}", dir.display())]
28    NpmPackageTreeCorrupted { dir: std::path::PathBuf },
29    #[error("npm package tree is corrupted: {} ({detail})", dir.display())]
30    NpmPackageTreeCorruptedDetail {
31        dir: std::path::PathBuf,
32        detail: String,
33    },
34    #[error("npm cache entry is not usable")]
35    NpmCacheEntryUnusable,
36    #[error("npm manifest is not usable")]
37    NpmManifestUnusable,
38    #[error("npm vendor manifest is not usable")]
39    NpmVendorManifestUnusable,
40    #[error("npm file is missing: {rel}")]
41    NpmFileMissing { rel: String },
42    #[error("npm project root is not usable: {source}")]
43    NpmProjectRootUnusable { source: Arc<std::io::Error> },
44    #[error("Invalid npm package.json: {source}")]
45    InvalidNpmPackageJson { source: Arc<serde_json::Error> },
46    #[error("npm manifest is too large")]
47    NpmManifestTooLarge,
48    #[error("npm manifest digest mismatch")]
49    NpmManifestDigestMismatch,
50    #[error("npm manifest changed unexpectedly")]
51    NpmManifestChanged,
52    #[error("npm manifest does not match its locked files")]
53    NpmManifestFilesMismatch,
54    #[error("npm archive changed")]
55    NpmArchiveChanged,
56    #[error("npm path is not usable")]
57    NpmPathUnusable,
58    #[error("{detail}")]
59    NpmStaging { detail: String },
60    #[error("npm staging collided repeatedly")]
61    NpmStagingCollided,
62    #[error("npm staged file changed: {rel}")]
63    NpmStagedFileChanged { rel: String },
64
65    #[error("npm staged file set changed")]
66    NpmStagedFileSetChanged,
67    #[error("npm lock path is not usable")]
68    NpmLockPathUnusable,
69    #[error("npm vendor tree is corrupted: {rel}")]
70    NpmVendorTreeCorrupted { rel: String },
71    #[error("npm vendor tree is not usable: {source}")]
72    NpmVendorTreeUnusable { source: Arc<std::io::Error> },
73    #[error("npm vendor tree is corrupted: {detail}")]
74    NpmVendorTreeCorruptedDetail { detail: String },
75    #[error("npm tarball is too large: {name}@{version}")]
76    NpmTarballTooLarge { name: String, version: String },
77    #[error("npm archive changed for {name}@{version}")]
78    NpmArchiveChangedFor { name: String, version: String },
79    #[error("npm pin is missing its manifest")]
80    NpmPinMissingManifest,
81    #[error("npm backing file changed: {rel}")]
82    NpmBackingFileChanged { rel: String },
83
84    #[error("npm tarball has a duplicate file: {path}")]
85    NpmTarballDuplicate { path: String },
86
87    #[error("Invalid npm manifest")]
88    InvalidNpmManifest,
89
90    #[error("npm file integrity mismatch: {rel}")]
91    NpmFileIntegrityMismatch { rel: String },
92
93    #[error("npm manifest is missing")]
94    NpmManifestMissing,
95
96    #[error("npm pin is missing package.json")]
97    NpmPinMissingPackageJson,
98
99    #[error(transparent)]
100    Npm(Box<super::NpmError>),
101    #[error(transparent)]
102    Io(std::sync::Arc<std::io::Error>),
103    #[error(transparent)]
104    Json(std::sync::Arc<serde_json::Error>),
105}
106
107impl From<std::io::Error> for StorageError {
108    fn from(error: std::io::Error) -> Self {
109        Self::Io(std::sync::Arc::new(error))
110    }
111}
112
113impl From<serde_json::Error> for StorageError {
114    fn from(error: serde_json::Error) -> Self {
115        Self::Json(std::sync::Arc::new(error))
116    }
117}
118
119impl From<super::NpmError> for StorageError {
120    fn from(error: super::NpmError) -> Self {
121        Self::Npm(Box::new(error))
122    }
123}
124
125fn npm_root(options: &Options) -> PathBuf {
126    options.project_root.join(".avril").join("npm")
127}
128
129fn registry_tag(registry: &Url) -> String {
130    crate::registry::artifact_key(registry.as_str())
131}
132
133pub(crate) fn npm_package_dir(
134    options: &Options,
135    instance: &str,
136    name: &str,
137    version: &str,
138) -> PathBuf {
139    npm_root(options)
140        .join(instance)
141        .join(registry_tag(&options.npm_registry))
142        .join(name)
143        .join(version)
144}
145
146/// Dependency-free instances share the acquisition tree; other instances
147/// retain their own paths, with hard-linked content where supported.
148pub(crate) fn npm_disk_instance<'a>(id: &'a str, package: &str) -> &'a str {
149    if id == package {
150        super::NPM_DEFAULT_INSTANCE
151    } else {
152        id
153    }
154}
155
156fn packument_cache_path(options: &Options, name: &str) -> PathBuf {
157    let safe = name.replace('/', "%2f");
158    npm_root(options)
159        .join(".metadata")
160        .join("packuments")
161        .join(registry_tag(&options.npm_registry))
162        .join(format!("{safe}.json"))
163}
164
165fn tarball_cache_path(options: &Options, tarball: &str) -> PathBuf {
166    npm_root(options)
167        .join(".artifacts")
168        .join("tarballs")
169        .join(crate::registry::artifact_key(tarball))
170}
171
172pub(crate) fn npm_vendor_relative(
173    options: &Options,
174    instance: &str,
175    registry: &Url,
176    name: &str,
177    version: &str,
178) -> Result<PathBuf, StorageError> {
179    super::validate_npm_instance_id(instance)?;
180    super::validate_npm_name(name)?;
181    super::validate_npm_version(version)?;
182    if !super::same_origin(registry, &options.npm_registry) {
183        return Err(StorageError::NpmVendorRegistryMismatch);
184    }
185    Ok(PathBuf::from("npm")
186        .join(instance)
187        .join(registry_tag(registry))
188        .join(name)
189        .join(version))
190}
191
192fn relative_path(anchor: &Path, path: &Path) -> Result<PathBuf, StorageError> {
193    let rel = path
194        .strip_prefix(anchor)
195        .map_err(|_| StorageError::NpmPathEscapesProject)?;
196    let mut out = PathBuf::new();
197    for part in rel.components() {
198        let Component::Normal(name) = part else {
199            return Err(StorageError::NpmPathEscapesProject);
200        };
201        out.push(name);
202    }
203    Ok(out)
204}
205
206fn open_project(options: &Options) -> Result<Dir, StorageError> {
207    fs::open_root(&options.project_root)
208        .map_err(|source| StorageError::NpmProjectRootUnusable { source: Arc::new(source) })
209}
210
211// No-follow open, fstat-confirmed regular file, bounded read.
212fn read_file_in_dir(
213    directory: &Dir,
214    name: &str,
215    rel: &str,
216    max_bytes: usize,
217) -> Result<Vec<u8>, StorageError> {
218    let file = fs::open_file(directory, name)
219        .map_err(|_| StorageError::NpmFileMissing { rel: rel.to_owned() })?;
220    Ok(fs::read_bounded(&file, max_bytes as u64, rel)?)
221}
222
223pub(crate) fn read_project_file(
224    project_root: &Path,
225    dir: &Path,
226    rel: &str,
227    max_bytes: usize,
228) -> Result<Vec<u8>, StorageError> {
229    let root = fs::open_root(project_root)?;
230    super::validate_npm_relpath(rel)?;
231    let dir_rel = relative_path(project_root, dir)?;
232    let package = fs::walk(&root, &dir_rel)?;
233    let (parent, name) = rel.rsplit_once('/').unwrap_or(("", rel));
234    let directory = fs::walk(&package, Path::new(parent))
235        .map_err(|_| StorageError::NpmFileMissing { rel: rel.to_owned() })?;
236    read_file_in_dir(&directory, name, rel, max_bytes)
237}
238
239// Verifies every pinned file with one bounded read each and returns the
240// package document parsed from those same bytes.
241fn verify_and_load(
242    package: &Dir,
243    files: &BTreeMap<String, String>,
244) -> Result<serde_json::Value, StorageError> {
245    let mut package_bytes: Option<Vec<u8>> = None;
246    let mut parent_cache: Option<(&str, Dir)> = None;
247    for (rel, expected) in files {
248        super::validate_npm_relpath(rel)?;
249        let (parent, name) = rel.rsplit_once('/').unwrap_or(("", rel));
250        let parent_path = Path::new(parent);
251        if parent_cache
252            .as_ref()
253            .is_none_or(|(cached, _)| Path::new(cached) != parent_path)
254        {
255            let directory = fs::walk(package, parent_path)
256                .map_err(|_| StorageError::NpmFileMissing { rel: rel.to_owned() })?;
257            parent_cache = Some((parent, directory));
258        }
259        let directory = &parent_cache.as_ref().unwrap().1;
260        let bytes = read_file_in_dir(directory, name, rel, MAX_NPM_FILE_BYTES)?;
261        if crate::registry::checksum(&bytes) != *expected {
262            return Err(StorageError::NpmFileIntegrityMismatch { rel: rel.to_owned() });
263        }
264        if *rel == "package.json" {
265            package_bytes = Some(bytes);
266        }
267    }
268    let package_bytes = package_bytes.ok_or(StorageError::NpmPinMissingPackageJson)?;
269    serde_json::from_slice(&package_bytes)
270        .map_err(|source| StorageError::InvalidNpmPackageJson { source: Arc::new(source) })
271}
272
273pub(crate) fn verify_and_load_npm_dir(
274    project_root: &Path,
275    dir: &Path,
276    files: &BTreeMap<String, String>,
277) -> Result<serde_json::Value, StorageError> {
278    let root = fs::open_root(project_root)?;
279    let dir_rel = relative_path(project_root, dir)?;
280    let package = fs::walk(&root, &dir_rel)?;
281    verify_and_load(&package, files)
282}
283
284#[derive(Clone, PartialEq, Eq)]
285pub(crate) enum NpmDirState {
286    Absent,
287    Verified(serde_json::Value),
288}
289
290pub(crate) fn verify_npm_metadata(
291    project_root: &Path,
292    dir: &Path,
293    files: &BTreeMap<String, String>,
294) -> Result<Option<serde_json::Value>, StorageError> {
295    let root = fs::open_root(project_root)?;
296    let dir_rel = relative_path(project_root, dir)?;
297    let package = match fs::walk(&root, &dir_rel) {
298        Ok(package) => package,
299        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
300        Err(_) => return Err(StorageError::NpmPackageTreeCorrupted { dir: dir.to_owned() }),
301    };
302    let expected = files
303        .get("package.json")
304        .ok_or(StorageError::NpmPinMissingPackageJson)?;
305    let result = (|| {
306        let bytes = read_file_in_dir(&package, "package.json", "package.json", MAX_NPM_FILE_BYTES)?;
307        if crate::registry::checksum(&bytes) != *expected {
308            return Err(StorageError::NpmFileIntegrityMismatch { rel: "package.json".into() });
309        }
310        serde_json::from_slice(&bytes)
311            .map_err(|source| StorageError::InvalidNpmPackageJson { source: Arc::new(source) })
312    })();
313    result
314        .map(Some)
315        .map_err(|error| StorageError::NpmPackageTreeCorruptedDetail {
316            dir: dir.to_owned(),
317            detail: error.to_string(),
318        })
319}
320
321pub(crate) fn npm_dir_state(
322    project_root: &Path,
323    dir: &Path,
324    files: &BTreeMap<String, String>,
325) -> Result<NpmDirState, StorageError> {
326    let root = fs::open_root(project_root)?;
327    let dir_rel = relative_path(project_root, dir)?;
328    let package = match fs::walk(&root, &dir_rel) {
329        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
330            return Ok(NpmDirState::Absent);
331        }
332        Err(_) => {
333            return Err(StorageError::NpmPackageTreeCorrupted { dir: dir.to_owned() });
334        }
335        Ok(package) => package,
336    };
337    match verify_and_load(&package, files) {
338        Ok(package) => Ok(NpmDirState::Verified(package)),
339        Err(error) => Err(StorageError::NpmPackageTreeCorruptedDetail {
340            dir: dir.to_owned(),
341            detail: error.to_string(),
342        }),
343    }
344}
345
346/// Readiness is not authentication: contents are verified when consumed. Missing
347/// storage is restored during async preparation, never by synchronous require.
348pub(crate) fn npm_instance_ready(
349    options: &Options,
350    dir: &Path,
351    backing: &Path,
352    pin: &NpmPin,
353) -> Result<bool, StorageError> {
354    if pin.is_legacy() && !options.frozen {
355        return Ok(false);
356    }
357    let root = open_project(options)?;
358    if pin.files.is_none() {
359        let digest = pin
360            .manifest
361            .as_ref()
362            .ok_or(StorageError::NpmPinMissingManifest)?;
363        let rel = manifest_rel(digest)?;
364        let parent = match fs::walk(&root, rel.parent().unwrap()) {
365            Ok(parent) => parent,
366            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
367            Err(_) => return Err(StorageError::NpmManifestUnusable),
368        };
369        match fs::open_file(&parent, rel.file_name().unwrap().to_str().unwrap()) {
370            Ok(file) if file.metadata()?.is_file() => {}
371            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
372            _ => return Err(StorageError::NpmManifestUnusable),
373        }
374    }
375    for path in std::iter::once(dir).chain((dir != backing).then_some(backing)) {
376        match fs::walk(&root, &relative_path(&options.project_root, path)?) {
377            Ok(_) => {}
378            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
379            Err(_) => return Err(StorageError::NpmPackageTreeCorrupted { dir: path.to_owned() }),
380        }
381    }
382    Ok(true)
383}
384
385pub(crate) fn ensure_npm_instance(
386    options: &Options,
387    backing: &NpmArtifact,
388    instance: &str,
389) -> Result<PathBuf, StorageError> {
390    super::validate_npm_instance_id(instance)?;
391    let identity = &backing.identity;
392    let files = &backing.files;
393    let dir = npm_package_dir(options, instance, &identity.name, &identity.version);
394    // Acquisition already authenticated the metadata at this exact path.
395    if dir == backing.dir {
396        return Ok(dir);
397    }
398    if verify_npm_metadata(&options.project_root, &dir, files)?.is_some() {
399        return Ok(dir);
400    }
401    let _guard = lock_package(options, &identity.key())?;
402    if verify_npm_metadata(&options.project_root, &dir, files)?.is_some() {
403        return Ok(dir);
404    }
405    let root = open_project(options)?;
406    let source_rel = relative_path(&options.project_root, &backing.dir)?;
407    let destination_rel = relative_path(&options.project_root, &dir)?;
408    let source = fs::walk(&root, &source_rel)?;
409    publish_dir(&root, &destination_rel, |staging| {
410        for (rel, expected) in files {
411            link_or_copy_file(&source, staging, rel, expected)?;
412        }
413        Ok(())
414    })?;
415    verify_and_load_npm_dir(&options.project_root, &dir, files)?;
416    Ok(dir)
417}
418
419fn link_or_copy_file(
420    source: &Dir,
421    staging: &Dir,
422    rel: &str,
423    expected: &str,
424) -> Result<(), StorageError> {
425    super::validate_npm_relpath(rel)?;
426    let (parent, name) = rel.rsplit_once('/').unwrap_or(("", rel));
427    let source_parent = fs::walk(source, Path::new(parent))?;
428    let bytes = read_file_in_dir(&source_parent, name, rel, MAX_NPM_FILE_BYTES)?;
429    if crate::registry::checksum(&bytes) != expected {
430        return Err(StorageError::NpmFileIntegrityMismatch { rel: rel.to_owned() });
431    }
432    let mut destination_parent = staging.try_clone()?;
433    for part in parent.split('/').filter(|part| !part.is_empty()) {
434        destination_parent = match destination_parent.open_dir_nofollow(part) {
435            Ok(child) => child,
436            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
437                destination_parent.create_dir(part)?;
438                destination_parent.open_dir_nofollow(part)?
439            }
440            Err(error) => return Err(StorageError::NpmStaging { detail: error.to_string() }),
441        };
442    }
443    match source_parent.hard_link(name, &destination_parent, name) {
444        Ok(()) => Ok(()),
445        Err(_) => stage_file(staging, rel, &bytes, expected),
446    }
447}
448
449// Missing entries read as absent; anything else unusable fails.
450fn read_cache(root: &Dir, rel: &Path, max_bytes: usize) -> Result<Option<Vec<u8>>, StorageError> {
451    let (parent_rel, name) = match rel.parent().zip(rel.file_name()) {
452        Some((parent, name)) => (parent.to_owned(), name.to_owned()),
453        None => return Err(StorageError::NpmCacheEntryUnusable),
454    };
455    let name = name.to_str().ok_or(StorageError::NpmCacheEntryUnusable)?;
456    let parent = match fs::walk(root, &parent_rel) {
457        Ok(parent) => parent,
458        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
459        Err(_) => return Err(StorageError::NpmCacheEntryUnusable),
460    };
461    let file = match fs::open_file(&parent, name) {
462        Ok(file) => file,
463        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
464        Err(_) => return Err(StorageError::NpmCacheEntryUnusable),
465    };
466    match fs::read_bounded(&file, max_bytes as u64, name) {
467        Ok(bytes) => Ok(Some(bytes)),
468        Err(_) => Err(StorageError::NpmCacheEntryUnusable),
469    }
470}
471
472pub(crate) fn read_packument_cache(
473    options: &Options,
474    name: &str,
475    max_bytes: usize,
476) -> Result<Option<Vec<u8>>, StorageError> {
477    let root = open_project(options)?;
478    let rel = relative_path(&options.project_root, &packument_cache_path(options, name))?;
479    read_cache(&root, &rel, max_bytes)
480}
481
482pub(crate) fn read_tarball_cache(
483    options: &Options,
484    tarball: &str,
485    max_bytes: usize,
486) -> Result<Option<Vec<u8>>, StorageError> {
487    let root = open_project(options)?;
488    let rel = relative_path(&options.project_root, &tarball_cache_path(options, tarball))?;
489    read_cache(&root, &rel, max_bytes)
490}
491
492fn write_cache(root: &Dir, rel: &Path, bytes: &[u8]) -> Result<(), StorageError> {
493    let (parent_rel, name) = match rel.parent().zip(rel.file_name()) {
494        Some((parent, name)) => (parent.to_owned(), name.to_owned()),
495        None => return Err(StorageError::NpmCacheEntryUnusable),
496    };
497    let name = name
498        .to_str()
499        .ok_or(StorageError::NpmCacheEntryUnusable)?
500        .to_owned();
501    let parent = fs::mkdirs(root, &parent_rel)?;
502    Ok(fs::write_atomic(&parent, &name, bytes)?)
503}
504
505pub(crate) fn write_vendor_file(
506    destination: &Path,
507    relative: &Path,
508    bytes: &[u8],
509) -> Result<(), StorageError> {
510    std::fs::create_dir_all(destination)?;
511    let root = fs::open_root(destination)?;
512    write_cache(&root, relative, bytes)
513}
514
515pub(crate) fn write_packument_cache(
516    options: &Options,
517    name: &str,
518    bytes: &[u8],
519) -> Result<(), StorageError> {
520    let root = open_project(options)?;
521    let rel = relative_path(&options.project_root, &packument_cache_path(options, name))?;
522    write_cache(&root, &rel, bytes)
523}
524
525pub(crate) fn write_tarball_cache(
526    options: &Options,
527    tarball: &str,
528    bytes: &[u8],
529) -> Result<(), StorageError> {
530    let root = open_project(options)?;
531    let rel = relative_path(&options.project_root, &tarball_cache_path(options, tarball))?;
532    write_cache(&root, &rel, bytes)
533}
534
535const MAX_NPM_MANIFEST_BYTES: usize = 8 * 1024 * 1024;
536
537// Hash exactly the canonical bytes we store; peer instances share this manifest.
538pub(crate) fn manifest_digest(files: &BTreeMap<String, String>) -> Result<String, StorageError> {
539    Ok(crate::registry::checksum(&manifest_bytes(files)?))
540}
541
542fn manifest_bytes(files: &BTreeMap<String, String>) -> Result<Vec<u8>, StorageError> {
543    let bytes = serde_json::to_vec(files)?;
544    if bytes.len() > MAX_NPM_MANIFEST_BYTES {
545        return Err(StorageError::NpmManifestTooLarge);
546    }
547    Ok(bytes)
548}
549
550fn manifest_rel(digest: &str) -> Result<PathBuf, StorageError> {
551    Ok(PathBuf::from(".avril")
552        .join("npm")
553        .join(".manifests")
554        .join(super::manifest_file_name(digest)?))
555}
556
557fn vendor_manifest_rel(digest: &str) -> Result<PathBuf, StorageError> {
558    Ok(PathBuf::from("npm")
559        .join(".manifests")
560        .join(super::manifest_file_name(digest)?))
561}
562
563fn parse_manifest_map(
564    bytes: &[u8],
565    digest: &str,
566) -> Result<BTreeMap<String, String>, StorageError> {
567    if crate::registry::checksum(bytes) != digest {
568        return Err(StorageError::NpmManifestDigestMismatch);
569    }
570    let map: BTreeMap<String, String> =
571        serde_json::from_slice(bytes).map_err(|_| StorageError::InvalidNpmManifest)?;
572    super::validate_files_map(&map)?;
573    Ok(map)
574}
575
576/// Load and authenticate the project sidecar. Missing reads as absent;
577/// anything else unusable (symlink, FIFO, corrupt bytes, digest mismatch)
578/// fails closed with no repair.
579pub(crate) fn load_manifest_map(
580    options: &Options,
581    digest: &str,
582) -> Result<Option<BTreeMap<String, String>>, StorageError> {
583    let root = open_project(options)?;
584    let rel = manifest_rel(digest)?;
585    match read_cache(&root, &rel, MAX_NPM_MANIFEST_BYTES) {
586        Ok(None) => Ok(None),
587        Ok(Some(bytes)) => parse_manifest_map(&bytes, digest).map(Some),
588        Err(StorageError::NpmCacheEntryUnusable) => Err(StorageError::NpmManifestUnusable),
589        Err(error) => Err(error),
590    }
591}
592
593/// Load and authenticate the vendor mirror sidecar. Unconfigured vendor
594/// roots and absent sidecars read as missing; anything else fails.
595pub(crate) fn load_vendor_map(
596    options: &Options,
597    digest: &str,
598) -> Result<Option<BTreeMap<String, String>>, StorageError> {
599    let Some(vendor) = open_vendor(options)? else {
600        return Ok(None);
601    };
602    let rel = vendor_manifest_rel(digest)?;
603    match read_cache(&vendor, &rel, MAX_NPM_MANIFEST_BYTES) {
604        Ok(None) => Ok(None),
605        Ok(Some(bytes)) => parse_manifest_map(&bytes, digest).map(Some),
606        Err(StorageError::NpmCacheEntryUnusable) => Err(StorageError::NpmVendorManifestUnusable),
607        Err(error) => Err(error),
608    }
609}
610
611/// Publish the sidecar for an authenticated map, creating it only when
612/// absent; existing divergent bytes fail instead of being repaired.
613pub(crate) fn ensure_manifest_sidecar(
614    options: &Options,
615    files: &BTreeMap<String, String>,
616) -> Result<String, StorageError> {
617    super::validate_files_map(files)?;
618    let bytes = manifest_bytes(files)?;
619    let digest = crate::registry::checksum(&bytes);
620    let root = open_project(options)?;
621    let rel = manifest_rel(&digest)?;
622    match read_cache(&root, &rel, MAX_NPM_MANIFEST_BYTES) {
623        Ok(None) => {
624            write_cache(&root, &rel, &bytes)?;
625            Ok(digest)
626        }
627        Ok(Some(existing)) => {
628            if crate::registry::checksum(&existing) != digest {
629                return Err(StorageError::NpmManifestChanged);
630            }
631            Ok(digest)
632        }
633        Err(StorageError::NpmCacheEntryUnusable) => Err(StorageError::NpmManifestUnusable),
634        Err(error) => Err(error),
635    }
636}
637
638/// Authenticated file map for vendoring: legacy inline maps directly,
639/// compact pins through the project sidecar, with a tarball-cache
640/// recovery that re-checks the digest. Never lists the tree.
641pub(crate) fn load_vendor_source_files(
642    options: &Options,
643    identity_key: &str,
644    pin: &NpmPin,
645) -> Result<BTreeMap<String, String>, StorageError> {
646    if let Some(files) = &pin.files {
647        if let Some(manifest) = &pin.manifest
648            && manifest_digest(files)? != *manifest
649        {
650            return Err(StorageError::NpmManifestFilesMismatch);
651        }
652        super::validate_files_map(files)?;
653        return Ok(files.clone());
654    }
655    let manifest = pin
656        .manifest
657        .as_ref()
658        .ok_or(StorageError::NpmPinMissingManifest)?;
659    if let Some(map) = load_manifest_map(options, manifest)? {
660        return Ok(map);
661    }
662    let bytes = read_tarball_cache(options, &pin.tarball, MAX_NPM_TARBALL_COMPRESSED)?
663        .ok_or(StorageError::NpmManifestMissing)?;
664    super::verify_sri(&bytes, &pin.integrity)?;
665    let (_, name, version) = super::parse_npm_key(identity_key)?;
666    let data = super::collect_npm_files(&bytes)?;
667    let (derived, _) = super::bind_npm_files(&data, &name, &version)?;
668    if manifest_digest(&derived)? != *manifest {
669        return Err(StorageError::NpmArchiveChanged);
670    }
671    ensure_manifest_sidecar(options, &derived)?;
672    Ok(derived)
673}
674
675/// Canonical sidecar bytes for the vendor mirror.
676pub(crate) fn write_vendor_manifest(
677    destination: &Path,
678    files: &BTreeMap<String, String>,
679) -> Result<(), StorageError> {
680    let bytes = manifest_bytes(files)?;
681    let digest = crate::registry::checksum(&bytes);
682    write_vendor_file(destination, &vendor_manifest_rel(&digest)?, &bytes)
683}
684
685// Stage under a unique name in the central staging area, then rename over
686// the destination. The rename is the only destination mutation, so a
687// concurrent verified winner stays complete. A swapped symlink at the
688// destination fails verification instead of being trusted.
689fn publish_dir(
690    root: &Dir,
691    dest_rel: &Path,
692    fill: impl FnOnce(&Dir) -> Result<(), StorageError>,
693) -> Result<(), StorageError> {
694    let (parent_rel, name) = match dest_rel.parent().zip(dest_rel.file_name()) {
695        Some((parent, name)) => (parent.to_owned(), name.to_owned()),
696        None => return Err(StorageError::NpmPathUnusable),
697    };
698    let name = name
699        .to_str()
700        .ok_or(StorageError::NpmPathUnusable)?
701        .to_owned();
702    let parent = fs::mkdirs(root, &parent_rel)?;
703    let staging_root = fs::mkdirs(root, Path::new(".avril/npm/.tmp"))?;
704    let staging = (|| -> Result<String, StorageError> {
705        for _ in 0..8 {
706            let candidate = fs::unique_tmp();
707            match staging_root.create_dir(&candidate) {
708                Ok(()) => return Ok(candidate),
709                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
710                Err(error) => return Err(StorageError::NpmStaging { detail: error.to_string() }),
711            }
712        }
713        Err(StorageError::NpmStagingCollided)
714    })()?;
715    let staging_dir = staging_root.open_dir_nofollow(&staging)?;
716    if let Err(error) = fill(&staging_dir) {
717        fs::remove_all(&staging_root, &staging);
718        return Err(error);
719    }
720    match staging_root.rename(&staging, &parent, &name) {
721        Ok(()) => Ok(()),
722        Err(error)
723            if error.kind() == std::io::ErrorKind::AlreadyExists
724                || error.kind() == std::io::ErrorKind::DirectoryNotEmpty =>
725        {
726            fs::remove_all(&staging_root, &staging);
727            Ok(())
728        }
729        Err(error) => {
730            fs::remove_all(&staging_root, &staging);
731            Err(error.into())
732        }
733    }
734}
735
736fn stage_file(staging: &Dir, rel: &str, bytes: &[u8], expected: &str) -> Result<(), StorageError> {
737    super::validate_npm_relpath(rel)?;
738    if crate::registry::checksum(bytes) != expected {
739        return Err(StorageError::NpmStagedFileChanged { rel: rel.to_owned() });
740    }
741    let mut dir = staging.try_clone()?;
742    let mut parts: Vec<&str> = rel.split('/').collect();
743    let name = parts.pop().unwrap_or_default();
744    for part in parts {
745        dir = match dir.open_dir_nofollow(part) {
746            Ok(child) => child,
747            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
748                dir.create_dir(part)?;
749                dir.open_dir_nofollow(part)?
750            }
751            Err(error) => return Err(StorageError::NpmStaging { detail: error.to_string() }),
752        };
753    }
754    if fs::open_file(&dir, name).is_ok() {
755        return Err(StorageError::NpmTarballDuplicate { path: rel.to_owned() });
756    }
757    let file = fs::write_new(&dir, name, bytes)?;
758    fs::set_readable(&file)?;
759    file.sync_all()?;
760    Ok(())
761}
762
763fn stage_data_files(
764    staging: &Dir,
765    data: &BTreeMap<String, Vec<u8>>,
766    files: &BTreeMap<String, String>,
767) -> Result<(), StorageError> {
768    if data.len() != files.len() {
769        return Err(StorageError::NpmStagedFileSetChanged);
770    }
771    for (rel, bytes) in data {
772        let expected = files
773            .get(rel.as_str())
774            .ok_or(StorageError::NpmStagedFileChanged { rel: rel.to_owned() })?;
775        stage_file(staging, rel, bytes, expected)?;
776    }
777    Ok(())
778}
779
780// Per-package guard file locked on its opened inode. Verified hits return
781// before this runs, so reads take no lock.
782fn lock_package(options: &Options, identity_key: &str) -> Result<fs::Guard, StorageError> {
783    let root = open_project(options)?;
784    let guard = npm_root(options).join(".locks").join(format!(
785        "npm-{}",
786        crate::registry::artifact_key(identity_key)
787    ));
788    let rel = relative_path(&options.project_root, &guard)?;
789    let (parent_rel, name) = match rel.parent().zip(rel.file_name()) {
790        Some((parent, name)) => (parent.to_owned(), name.to_owned()),
791        None => return Err(StorageError::NpmLockPathUnusable),
792    };
793    let name = name.to_str().ok_or(StorageError::NpmLockPathUnusable)?;
794    let parent = fs::mkdirs(&root, &parent_rel)?;
795    Ok(fs::lock_file(&parent, name)?)
796}
797
798/// Create `.avril/.gitignore` containing `*` when absent, leaving any
799/// existing file exactly alone.
800pub(crate) fn ensure_npm_gitignore(project_root: &Path) -> Result<(), StorageError> {
801    let root = fs::open_root(project_root)?;
802    let avril = fs::mkdirs(&root, Path::new(".avril"))?;
803    match fs::open_file(&avril, ".gitignore") {
804        Ok(_) => Ok(()),
805        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
806            match fs::write_new(&avril, ".gitignore", b"*\n") {
807                Ok(file) => Ok(file.sync_all()?),
808                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
809                Err(error) => Err(error.into()),
810            }
811        }
812        Err(error) => Err(error.into()),
813    }
814}
815
816pub(crate) fn read_npm_project_file(
817    options: &Options,
818    dir: &Path,
819    rel: &str,
820) -> Result<Vec<u8>, StorageError> {
821    read_project_file(&options.project_root, dir, rel, MAX_NPM_FILE_BYTES)
822}
823
824/// Registry coordinates of one published archive, as recorded by a lockfile.
825pub(crate) struct NpmArchive<'a> {
826    pub registry: &'a Url,
827    pub name: &'a str,
828    pub version: &'a str,
829    pub tarball: &'a str,
830    pub integrity: &'a str,
831}
832
833fn build_artifact(
834    archive: NpmArchive<'_>,
835    files: BTreeMap<String, String>,
836    dir: PathBuf,
837    package: serde_json::Value,
838) -> NpmArtifact {
839    NpmArtifact {
840        identity: NpmIdentity {
841            registry: archive.registry.clone(),
842            name: archive.name.to_owned(),
843            version: archive.version.to_owned(),
844        },
845        tarball: archive.tarball.to_owned(),
846        integrity: archive.integrity.to_owned(),
847        dir,
848        files,
849        package,
850    }
851}
852
853fn read_vendor_file(
854    vendor: &Dir,
855    vendor_rel: &Path,
856    rel: &str,
857    expected: &str,
858) -> Result<Vec<u8>, StorageError> {
859    super::validate_npm_relpath(rel)?;
860    let mut dir_rel = vendor_rel.to_owned();
861    let mut parts: Vec<&str> = rel.split('/').collect();
862    let name = parts.pop().unwrap_or_default();
863    for part in parts {
864        dir_rel.push(part);
865    }
866    let parent = fs::walk(vendor, &dir_rel)
867        .map_err(|_| StorageError::NpmVendorTreeCorrupted { rel: rel.to_owned() })?;
868    let file = fs::open_file(&parent, name)
869        .map_err(|_| StorageError::NpmVendorTreeCorrupted { rel: rel.to_owned() })?;
870    let bytes = fs::read_bounded(&file, MAX_NPM_FILE_BYTES as u64, rel)
871        .map_err(|_| StorageError::NpmVendorTreeCorrupted { rel: rel.to_owned() })?;
872    if crate::registry::checksum(&bytes) != expected {
873        return Err(StorageError::NpmVendorTreeCorrupted { rel: rel.to_owned() });
874    }
875    Ok(bytes)
876}
877
878fn verify_vendor_dir(
879    vendor: &Dir,
880    vendor_rel: &Path,
881    files: &BTreeMap<String, String>,
882) -> Result<serde_json::Value, StorageError> {
883    let mut package_bytes: Option<Vec<u8>> = None;
884    for (rel, expected) in files {
885        let bytes = read_vendor_file(vendor, vendor_rel, rel, expected)?;
886        if *rel == "package.json" {
887            package_bytes = Some(bytes);
888        }
889    }
890    let package_bytes = package_bytes.ok_or(StorageError::NpmPinMissingPackageJson)?;
891    serde_json::from_slice(&package_bytes)
892        .map_err(|source| StorageError::InvalidNpmPackageJson { source: Arc::new(source) })
893}
894
895// Absent or unconfigured vendor roots read as missing so acquisition falls
896// through; any other failure (symlinked parent, unreadable tree) fails.
897fn open_vendor(options: &Options) -> Result<Option<Dir>, StorageError> {
898    let Some(vendor) = options.vendor.as_ref() else {
899        return Ok(None);
900    };
901    match fs::open_root(vendor) {
902        Ok(root) => Ok(Some(root)),
903        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
904        Err(error) => Err(StorageError::NpmVendorTreeUnusable { source: Arc::new(error) }),
905    }
906}
907
908pub(crate) fn seed_npm_from_vendor(
909    options: &Options,
910    npm_registry: &Url,
911    instance: &str,
912    name: &str,
913    version: &str,
914    pin: &NpmPin,
915    files: &BTreeMap<String, String>,
916) -> Result<Option<NpmArtifact>, StorageError> {
917    let Some(vendor) = open_vendor(options)? else {
918        return Ok(None);
919    };
920    let vendor_rel: PathBuf = [
921        "npm",
922        instance,
923        &registry_tag(&options.npm_registry),
924        name,
925        version,
926    ]
927    .iter()
928    .collect();
929    // A missing vendor subtree falls through to verified sources; anything
930    // present but unreadable fails below.
931    if let Err(error) = fs::walk(&vendor, &vendor_rel) {
932        if error.kind() == std::io::ErrorKind::NotFound {
933            return Ok(None);
934        }
935        return Err(StorageError::NpmVendorTreeCorruptedDetail { detail: error.to_string() });
936    }
937    let package = verify_vendor_dir(&vendor, &vendor_rel, files)?;
938    let root = open_project(options)?;
939    let dir = npm_package_dir(options, instance, name, version);
940    let dir_rel = relative_path(&options.project_root, &dir)?;
941    publish_dir(&root, &dir_rel, |staging| {
942        for (rel, expected_hash) in files {
943            let bytes = read_vendor_file(&vendor, &vendor_rel, rel, expected_hash)?;
944            stage_file(staging, rel, &bytes, expected_hash)?;
945        }
946        Ok(())
947    })?;
948    let verified = verify_and_load_npm_dir(&options.project_root, &dir, files)?;
949    debug_assert_eq!(verified, package);
950    ensure_manifest_sidecar(options, files)?;
951    ensure_npm_gitignore(&options.project_root)?;
952    Ok(Some(build_artifact(
953        NpmArchive {
954            registry: npm_registry,
955            name,
956            version,
957            tarball: &pin.tarball,
958            integrity: &pin.integrity,
959        },
960        files.clone(),
961        dir,
962        verified,
963    )))
964}
965
966// Materialize one verified tarball under a per-package lock. Read-only
967// hits return before any locking or writing.
968pub(crate) fn materialize_npm(
969    options: &Options,
970    authority: &Option<crate::authority::SourceAuthority>,
971    archive: NpmArchive<'_>,
972    instance: &str,
973    bytes: Vec<u8>,
974    expected: Option<&NpmPin>,
975) -> Result<NpmArtifact, StorageError> {
976    let NpmArchive {
977        registry: npm_registry,
978        name,
979        version,
980        tarball,
981        integrity,
982    } = archive;
983    if bytes.len() > MAX_NPM_TARBALL_COMPRESSED {
984        return Err(StorageError::NpmTarballTooLarge {
985            name: name.to_owned(),
986            version: version.to_owned(),
987        });
988    }
989    super::verify_sri(&bytes, integrity)?;
990    let data = super::collect_npm_files(&bytes)?;
991    let (files, _) = super::bind_npm_files(&data, name, version)?;
992    if let Some(expected) = expected {
993        if expected.tarball != tarball || expected.integrity != integrity {
994            return Err(StorageError::NpmArchiveChangedFor {
995                name: name.to_owned(),
996                version: version.to_owned(),
997            });
998        }
999        match (&expected.manifest, &expected.files) {
1000            (Some(manifest), _) => {
1001                if manifest_digest(&files)? != *manifest {
1002                    return Err(StorageError::NpmArchiveChangedFor {
1003                        name: name.to_owned(),
1004                        version: version.to_owned(),
1005                    });
1006                }
1007            }
1008            (None, Some(locked)) => {
1009                if *locked != files {
1010                    return Err(StorageError::NpmArchiveChangedFor {
1011                        name: name.to_owned(),
1012                        version: version.to_owned(),
1013                    });
1014                }
1015            }
1016            (None, None) => {
1017                return Err(StorageError::NpmPinMissingManifest);
1018            }
1019        }
1020    }
1021    super::check_npm_exec(authority.as_ref(), npm_registry, name, version, &files)?;
1022    let dir = npm_package_dir(options, instance, name, version);
1023    if npm_dir_state(&options.project_root, &dir, &files)? == NpmDirState::Absent {
1024        let _guard = lock_package(
1025            options,
1026            &format!("{}|{name}|{version}", npm_registry.as_str()),
1027        )?;
1028        if npm_dir_state(&options.project_root, &dir, &files)? == NpmDirState::Absent {
1029            let root = open_project(options)?;
1030            let dir_rel = relative_path(&options.project_root, &dir)?;
1031            publish_dir(&root, &dir_rel, |staging| {
1032                stage_data_files(staging, &data, &files)
1033            })?;
1034            ensure_npm_gitignore(&options.project_root)?;
1035        }
1036    }
1037    let package = verify_and_load_npm_dir(&options.project_root, &dir, &files)?;
1038    ensure_manifest_sidecar(options, &files)?;
1039    Ok(NpmArtifact {
1040        identity: NpmIdentity {
1041            registry: npm_registry.clone(),
1042            name: name.to_owned(),
1043            version: version.to_owned(),
1044        },
1045        tarball: tarball.to_owned(),
1046        integrity: integrity.to_owned(),
1047        dir,
1048        files,
1049        package,
1050    })
1051}