Skip to main content

osdk_core/
inventory.rs

1//! Persisted inventory for dynamic tool installs.
2//!
3//! Each dynamic install root may contain a `.osdk-install.json` manifest describing
4//! its complete install identity and owned bins. This module
5//! provides strict serde-backed types plus safe helpers to persist, scan, and
6//! reason about those manifests without following symlinks.
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::io::{Read, Write};
10use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::{Error, Result};
15use crate::tool::InstallIdentity;
16use crate::version::ToolRequest;
17
18/// Name of the per-install dynamic tool inventory file.
19pub const INVENTORY_FILE: &str = ".osdk-install.json";
20/// Previous inventory filename. It is detected for migration/reporting only.
21pub const LEGACY_INVENTORY_FILE: &str = ".osdk-tool.json";
22
23const INVENTORY_SCHEMA: u32 = 1;
24const DEFAULT_MAX_DEPTH: usize = 8;
25const DEFAULT_MAX_MANIFEST_BYTES: u64 = 256 * 1024;
26const DEFAULT_MAX_MANIFESTS: usize = 4096;
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct DynamicToolBin {
31    /// Exported executable name, e.g. `prettier` or `gh.exe`.
32    pub name: String,
33    /// Relative path from the install root to the executable.
34    pub path: String,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct DynamicToolManifest {
40    pub schema: u32,
41    pub identity: InstallIdentity,
42    /// Binaries exported by this install.
43    #[serde(default)]
44    pub bins: Vec<DynamicToolBin>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct InstalledDynamicTool {
49    pub canonical_id: String,
50    pub install_root: PathBuf,
51    pub manifest: DynamicToolManifest,
52    root_identity: FileIdentity,
53    manifest_identity: FileIdentity,
54}
55
56impl InstalledDynamicTool {
57    /// Revalidate that the install root and manifest path still name the same
58    /// filesystem objects accepted by the scanner. Consumers must call this
59    /// immediately before trusting bins or execution metadata from the record.
60    pub fn revalidate(&self) -> Result<()> {
61        validate_regular_directory_path(&self.install_root)
62            .map_err(|error| Error::io(&self.install_root, error))?;
63        let current_root = FileIdentity::from_path(&self.install_root, FileKind::Directory)
64            .map_err(|error| Error::io(&self.install_root, error))?;
65        if current_root != self.root_identity {
66            return Err(Error::other(format!(
67                "dynamic install root changed after inventory scan: {}",
68                self.install_root.display()
69            )));
70        }
71        let manifest_path = DynamicToolManifest::manifest_path(&self.install_root);
72        let current_manifest = FileIdentity::from_path(&manifest_path, FileKind::File)
73            .map_err(|error| Error::io(&manifest_path, error))?;
74        if current_manifest != self.manifest_identity {
75            return Err(Error::other(format!(
76                "dynamic install manifest changed after inventory scan: {}",
77                manifest_path.display()
78            )));
79        }
80        Ok(())
81    }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85struct FileIdentity {
86    canonical_path: PathBuf,
87    len: u64,
88    modified: Option<std::time::SystemTime>,
89    #[cfg(unix)]
90    device: u64,
91    #[cfg(unix)]
92    inode: u64,
93}
94
95#[derive(Debug, Clone, Copy)]
96enum FileKind {
97    Directory,
98    File,
99}
100
101impl FileIdentity {
102    fn from_path(path: &Path, kind: FileKind) -> std::io::Result<Self> {
103        let metadata = std::fs::symlink_metadata(path)?;
104        let valid_kind = match kind {
105            FileKind::Directory => metadata.is_dir(),
106            FileKind::File => metadata.is_file(),
107        };
108        if metadata.file_type().is_symlink() || !valid_kind {
109            return Err(std::io::Error::other(format!(
110                "inventory path is not a regular non-symlink {kind:?}: {}",
111                path.display()
112            )));
113        }
114        Ok(Self {
115            canonical_path: dunce::canonicalize(path)?,
116            len: metadata.len(),
117            modified: metadata.modified().ok(),
118            #[cfg(unix)]
119            device: {
120                use std::os::unix::fs::MetadataExt as _;
121                metadata.dev()
122            },
123            #[cfg(unix)]
124            inode: {
125                use std::os::unix::fs::MetadataExt as _;
126                metadata.ino()
127            },
128        })
129    }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct LegacyDynamicInstall {
134    pub install_root: PathBuf,
135    pub manifest_path: PathBuf,
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum CorruptManifestPolicy {
140    /// Refuse the entire scan when any manifest is unreadable or invalid.
141    FailClosed,
142    /// Skip invalid manifests and report diagnostics.
143    CollectDiagnostics,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct ScanOptions {
148    /// Maximum install-root depth below the scan root.
149    pub max_depth: usize,
150    /// Maximum accepted manifest size in bytes.
151    pub max_manifest_bytes: u64,
152    /// Maximum number of manifests to inspect in one scan.
153    pub max_manifests: usize,
154    /// How to handle corrupt or unreadable manifests.
155    pub corrupt_manifest_policy: CorruptManifestPolicy,
156}
157
158impl Default for ScanOptions {
159    fn default() -> Self {
160        Self {
161            max_depth: DEFAULT_MAX_DEPTH,
162            max_manifest_bytes: DEFAULT_MAX_MANIFEST_BYTES,
163            max_manifests: DEFAULT_MAX_MANIFESTS,
164            corrupt_manifest_policy: CorruptManifestPolicy::FailClosed,
165        }
166    }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum InventoryDiagnosticKind {
171    Walk,
172    Io,
173    ManifestTooLarge,
174    InvalidManifest,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct InventoryDiagnostic {
179    pub path: PathBuf,
180    pub kind: InventoryDiagnosticKind,
181    pub message: String,
182}
183
184#[derive(Debug, Clone, Default, PartialEq, Eq)]
185pub struct ScanReport {
186    pub installs: Vec<InstalledDynamicTool>,
187    pub legacy_installs: Vec<LegacyDynamicInstall>,
188    pub diagnostics: Vec<InventoryDiagnostic>,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct BinOwnerCandidate {
193    pub bin_name: String,
194    pub canonical_id: String,
195    pub install_root: PathBuf,
196    pub relative_path: String,
197}
198
199impl BinOwnerCandidate {
200    pub fn absolute_path(&self) -> PathBuf {
201        self.install_root.join(&self.relative_path)
202    }
203}
204
205impl DynamicToolManifest {
206    pub fn from_identity(identity: InstallIdentity) -> Result<Self> {
207        Self {
208            schema: INVENTORY_SCHEMA,
209            identity,
210            bins: Vec::new(),
211        }
212        .normalize()
213    }
214
215    pub fn manifest_path(install_root: &Path) -> PathBuf {
216        install_root.join(INVENTORY_FILE)
217    }
218
219    pub fn from_slice(bytes: &[u8]) -> Result<Self> {
220        let manifest: Self = serde_json::from_slice(bytes)?;
221        manifest.normalize()
222    }
223
224    pub fn load(install_root: &Path) -> Result<Self> {
225        let path = Self::manifest_path(install_root);
226        validate_regular_directory_path(install_root)
227            .map_err(|error| Error::io(install_root, error))?;
228        let bytes = read_stable_regular_file(&path, DEFAULT_MAX_MANIFEST_BYTES)
229            .map_err(|error| Error::io(&path, error))?;
230        Self::from_slice(&bytes).map_err(|error| {
231            Error::other(format!(
232                "invalid tool inventory at {}: {error}",
233                path.display()
234            ))
235        })
236    }
237
238    pub fn write_atomic(&self, install_root: &Path) -> Result<()> {
239        let path = Self::manifest_path(install_root);
240        let normalized = self.clone().normalize()?;
241        atomic_write_json(&path, &normalized)
242    }
243
244    pub fn normalize(mut self) -> Result<Self> {
245        if self.schema != INVENTORY_SCHEMA {
246            return Err(Error::config(format!(
247                "unsupported dynamic install inventory schema `{}`",
248                self.schema
249            )));
250        }
251        self.identity.validate()?;
252        normalize_bins(&mut self.bins)?;
253        Ok(self)
254    }
255
256    pub fn matches_identity(&self, identity: &InstallIdentity) -> bool {
257        &self.identity == identity
258    }
259}
260
261impl ScanReport {
262    pub fn installed_ids(&self) -> Vec<String> {
263        deduped_ids(
264            self.installs
265                .iter()
266                .map(|install| install.manifest.identity.tool.as_str()),
267        )
268    }
269}
270
271pub fn remove_manifest(install_root: &Path) -> Result<bool> {
272    let path = DynamicToolManifest::manifest_path(install_root);
273    match std::fs::remove_file(&path) {
274        Ok(()) => Ok(true),
275        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
276        Err(error) => Err(Error::io(path, error)),
277    }
278}
279
280pub fn canonical_dynamic_id(value: &str) -> Result<String> {
281    crate::tool::canonical_dynamic_id(value)
282}
283
284pub fn configured_dynamic_ids<'a>(
285    configured_values: impl IntoIterator<Item = (&'a str, &'a str)>,
286    config_keys: &[&str],
287) -> Vec<String> {
288    let relevant_keys: BTreeSet<&str> = config_keys.iter().copied().collect();
289    deduped_ids(
290        configured_values
291            .into_iter()
292            .filter_map(|(key, value)| relevant_keys.contains(key).then_some(value)),
293    )
294}
295
296pub fn configured_and_installed_dynamic_ids<'a>(
297    report: &ScanReport,
298    configured_values: impl IntoIterator<Item = (&'a str, &'a str)>,
299    config_keys: &[&str],
300) -> Vec<String> {
301    let mut ids = BTreeSet::new();
302    ids.extend(configured_dynamic_ids(configured_values, config_keys));
303    ids.extend(report.installed_ids());
304    ids.into_iter().collect()
305}
306
307pub fn build_bin_ownership_candidates(
308    installs: &[InstalledDynamicTool],
309) -> BTreeMap<String, Vec<BinOwnerCandidate>> {
310    let mut owners: BTreeMap<String, Vec<BinOwnerCandidate>> = BTreeMap::new();
311    for install in installs {
312        // A report is a snapshot. Refuse entries whose root or manifest was
313        // replaced after scanning instead of exporting stale ownership data.
314        if install.revalidate().is_err() {
315            continue;
316        }
317        for bin in &install.manifest.bins {
318            owners
319                .entry(bin.name.clone())
320                .or_default()
321                .push(BinOwnerCandidate {
322                    bin_name: bin.name.clone(),
323                    canonical_id: install.canonical_id.clone(),
324                    install_root: install.install_root.clone(),
325                    relative_path: bin.path.clone(),
326                });
327        }
328    }
329    for candidates in owners.values_mut() {
330        candidates.sort_by(|left, right| {
331            (
332                left.canonical_id.as_str(),
333                left.relative_path.as_str(),
334                path_sort_key(&left.install_root),
335            )
336                .cmp(&(
337                    right.canonical_id.as_str(),
338                    right.relative_path.as_str(),
339                    path_sort_key(&right.install_root),
340                ))
341        });
342        candidates.dedup_by(|left, right| {
343            left.canonical_id == right.canonical_id
344                && left.relative_path == right.relative_path
345                && left.install_root == right.install_root
346        });
347    }
348    owners
349}
350
351pub fn scan_installs(scan_root: &Path, options: &ScanOptions) -> Result<ScanReport> {
352    match std::fs::symlink_metadata(scan_root) {
353        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
354            return Ok(ScanReport::default());
355        }
356        Err(error) => return Err(Error::io(scan_root, error)),
357        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
358            return Err(Error::other(format!(
359                "dynamic tool inventory root must be a non-symlink directory: {}",
360                scan_root.display()
361            )));
362        }
363        Ok(_) => {}
364    }
365    validate_regular_directory_path(scan_root).map_err(|error| Error::io(scan_root, error))?;
366    let canonical_scan_root =
367        dunce::canonicalize(scan_root).map_err(|error| Error::io(scan_root, error))?;
368    let mut manifest_paths = Vec::new();
369    let mut legacy_installs = Vec::new();
370    let mut diagnostics = Vec::new();
371    let walker = walkdir::WalkDir::new(scan_root)
372        .follow_links(false)
373        .max_depth(options.max_depth.saturating_add(1))
374        .into_iter()
375        // Hidden directories below the install root are implementation state
376        // (`.locks`, atomic staging/backup trees, and similar). The inventory
377        // file itself is intentionally hidden, so filter directories only.
378        .filter_entry(|entry| {
379            entry.depth() == 0
380                || !entry.file_type().is_dir()
381                || !entry.file_name().to_string_lossy().starts_with('.')
382        });
383
384    for entry in walker {
385        match entry {
386            Ok(entry) => {
387                if entry.file_type().is_symlink() || !entry.file_type().is_file() {
388                    continue;
389                }
390                if entry.file_name() == LEGACY_INVENTORY_FILE {
391                    let manifest_path = entry.into_path();
392                    if let Some(install_root) = manifest_path.parent() {
393                        legacy_installs.push(LegacyDynamicInstall {
394                            install_root: install_root.to_path_buf(),
395                            manifest_path,
396                        });
397                    }
398                    if manifest_paths.len() + legacy_installs.len() > options.max_manifests {
399                        return Err(Error::other(format!(
400                            "dynamic tool inventory scan exceeded manifest limit of {} under {}",
401                            options.max_manifests,
402                            scan_root.display()
403                        )));
404                    }
405                    continue;
406                }
407                if entry.file_name() != INVENTORY_FILE {
408                    continue;
409                }
410                manifest_paths.push(entry.into_path());
411                if manifest_paths.len() > options.max_manifests {
412                    return Err(Error::other(format!(
413                        "dynamic tool inventory scan exceeded manifest limit of {} under {}",
414                        options.max_manifests,
415                        scan_root.display()
416                    )));
417                }
418            }
419            Err(error) => handle_scan_problem(
420                &mut diagnostics,
421                options,
422                error
423                    .path()
424                    .map(Path::to_path_buf)
425                    .unwrap_or_else(|| scan_root.to_path_buf()),
426                InventoryDiagnosticKind::Walk,
427                error.to_string(),
428            )?,
429        }
430    }
431
432    manifest_paths.sort_by_key(|path| path_sort_key(path));
433
434    let mut installs = Vec::new();
435    for manifest_path in manifest_paths {
436        let install_root = manifest_path
437            .parent()
438            .ok_or_else(|| {
439                Error::other(format!(
440                    "manifest path has no parent: {}",
441                    manifest_path.display()
442                ))
443            })?
444            .to_path_buf();
445        if let Err(error) = validate_regular_directory_path_from(scan_root, &install_root) {
446            handle_scan_problem(
447                &mut diagnostics,
448                options,
449                install_root.clone(),
450                InventoryDiagnosticKind::Io,
451                error.to_string(),
452            )?;
453            continue;
454        }
455        match dunce::canonicalize(&install_root) {
456            Ok(root) if root.starts_with(&canonical_scan_root) => {}
457            Ok(root) => {
458                handle_scan_problem(
459                    &mut diagnostics,
460                    options,
461                    install_root.clone(),
462                    InventoryDiagnosticKind::InvalidManifest,
463                    format!(
464                        "dynamic install root resolves outside {} to {}",
465                        canonical_scan_root.display(),
466                        root.display()
467                    ),
468                )?;
469                continue;
470            }
471            Err(error) => {
472                handle_scan_problem(
473                    &mut diagnostics,
474                    options,
475                    install_root.clone(),
476                    InventoryDiagnosticKind::Io,
477                    error.to_string(),
478                )?;
479                continue;
480            }
481        };
482        let bytes = match read_stable_regular_file(&manifest_path, options.max_manifest_bytes) {
483            Ok(bytes) => bytes,
484            Err(error) if error.kind() == std::io::ErrorKind::FileTooLarge => {
485                handle_scan_problem(
486                    &mut diagnostics,
487                    options,
488                    manifest_path.clone(),
489                    InventoryDiagnosticKind::ManifestTooLarge,
490                    format!(
491                        "manifest is larger than the {} byte limit",
492                        options.max_manifest_bytes
493                    ),
494                )?;
495                continue;
496            }
497            Err(error) => {
498                handle_scan_problem(
499                    &mut diagnostics,
500                    options,
501                    manifest_path.clone(),
502                    InventoryDiagnosticKind::Io,
503                    error.to_string(),
504                )?;
505                continue;
506            }
507        };
508        if bytes.len() as u64 > options.max_manifest_bytes {
509            handle_scan_problem(
510                &mut diagnostics,
511                options,
512                manifest_path.clone(),
513                InventoryDiagnosticKind::ManifestTooLarge,
514                format!(
515                    "manifest expanded to {} bytes, larger than the {} byte limit",
516                    bytes.len(),
517                    options.max_manifest_bytes
518                ),
519            )?;
520            continue;
521        }
522
523        let manifest = match DynamicToolManifest::from_slice(&bytes) {
524            Ok(manifest) => manifest,
525            Err(error) => {
526                handle_scan_problem(
527                    &mut diagnostics,
528                    options,
529                    manifest_path.clone(),
530                    InventoryDiagnosticKind::InvalidManifest,
531                    error.to_string(),
532                )?;
533                continue;
534            }
535        };
536        let canonical_root = crate::dirs::InstallLocator::is_canonical_install_root(
537            scan_root,
538            &manifest.identity,
539            &install_root,
540        );
541        if !matches!(canonical_root, Ok(true)) {
542            handle_scan_problem(
543                &mut diagnostics,
544                options,
545                manifest_path.clone(),
546                InventoryDiagnosticKind::InvalidManifest,
547                canonical_root.err().map_or_else(
548                    || "dynamic install manifest is not stored under its identity root".into(),
549                    |error| error.to_string(),
550                ),
551            )?;
552            continue;
553        }
554        let root_identity = match FileIdentity::from_path(&install_root, FileKind::Directory) {
555            Ok(identity) => identity,
556            Err(error) => {
557                handle_scan_problem(
558                    &mut diagnostics,
559                    options,
560                    install_root.clone(),
561                    InventoryDiagnosticKind::Io,
562                    error.to_string(),
563                )?;
564                continue;
565            }
566        };
567        let manifest_identity = match FileIdentity::from_path(&manifest_path, FileKind::File) {
568            Ok(identity) => identity,
569            Err(error) => {
570                handle_scan_problem(
571                    &mut diagnostics,
572                    options,
573                    manifest_path.clone(),
574                    InventoryDiagnosticKind::Io,
575                    error.to_string(),
576                )?;
577                continue;
578            }
579        };
580        installs.push(InstalledDynamicTool {
581            canonical_id: manifest.identity.tool.clone(),
582            install_root,
583            manifest,
584            root_identity,
585            manifest_identity,
586        });
587    }
588
589    let mut validated_legacy_installs = Vec::with_capacity(legacy_installs.len());
590    for legacy in legacy_installs {
591        if let Err(error) = validate_regular_directory_path_from(scan_root, &legacy.install_root) {
592            handle_scan_problem(
593                &mut diagnostics,
594                options,
595                legacy.manifest_path.clone(),
596                InventoryDiagnosticKind::Io,
597                error.to_string(),
598            )?;
599            continue;
600        }
601        match std::fs::symlink_metadata(&legacy.manifest_path) {
602            Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {
603                validated_legacy_installs.push(legacy);
604            }
605            Ok(_) => {}
606            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
607            Err(error) => {
608                handle_scan_problem(
609                    &mut diagnostics,
610                    options,
611                    legacy.manifest_path.clone(),
612                    InventoryDiagnosticKind::Io,
613                    error.to_string(),
614                )?;
615            }
616        }
617    }
618    let mut legacy_installs = validated_legacy_installs;
619
620    installs.sort_by(|left, right| {
621        (
622            left.canonical_id.as_str(),
623            path_sort_key(&left.install_root),
624            left.manifest.identity.version.as_str(),
625        )
626            .cmp(&(
627                right.canonical_id.as_str(),
628                path_sort_key(&right.install_root),
629                right.manifest.identity.version.as_str(),
630            ))
631    });
632
633    legacy_installs.sort_by(|left, right| {
634        path_sort_key(&left.manifest_path).cmp(&path_sort_key(&right.manifest_path))
635    });
636    Ok(ScanReport {
637        installs,
638        legacy_installs,
639        diagnostics,
640    })
641}
642
643/// Read a bounded regular file without following a final-component symlink and
644/// reject path replacement while the bytes are being consumed. Security-
645/// sensitive sidecar readers share this primitive with inventory scanning so
646/// they all have the same TOCTOU guarantees.
647pub(crate) fn read_stable_regular_file(path: &Path, max_bytes: u64) -> std::io::Result<Vec<u8>> {
648    let path_metadata = std::fs::symlink_metadata(path)?;
649    if path_metadata.file_type().is_symlink() || !path_metadata.is_file() {
650        return Err(std::io::Error::other(
651            "path must be a regular non-symlink file",
652        ));
653    }
654    if path_metadata.len() > max_bytes {
655        return Err(std::io::Error::new(
656            std::io::ErrorKind::FileTooLarge,
657            "file exceeds its size limit",
658        ));
659    }
660
661    let mut options = std::fs::OpenOptions::new();
662    options.read(true);
663    #[cfg(unix)]
664    {
665        use std::os::unix::fs::OpenOptionsExt as _;
666        options.custom_flags(libc::O_NOFOLLOW);
667    }
668    let file = options.open(path)?;
669    let opened_metadata = file.metadata()?;
670    if !opened_metadata.is_file() || !same_file_metadata(&path_metadata, &opened_metadata) {
671        return Err(std::io::Error::other(
672            "file changed while it was being opened",
673        ));
674    }
675    let opened_handle = same_file::Handle::from_file(file.try_clone()?)?;
676
677    let capacity = opened_metadata.len().min(max_bytes) as usize;
678    let mut bytes = Vec::with_capacity(capacity);
679    file.take(max_bytes.saturating_add(1))
680        .read_to_end(&mut bytes)?;
681    if bytes.len() as u64 > max_bytes {
682        return Err(std::io::Error::new(
683            std::io::ErrorKind::FileTooLarge,
684            "file exceeds its size limit",
685        ));
686    }
687
688    let current_metadata = std::fs::symlink_metadata(path)?;
689    if current_metadata.file_type().is_symlink()
690        || !current_metadata.is_file()
691        || !same_file_metadata(&opened_metadata, &current_metadata)
692        || same_file::Handle::from_path(path)? != opened_handle
693    {
694        return Err(std::io::Error::other(
695            "file path changed while it was being read",
696        ));
697    }
698    Ok(bytes)
699}
700
701#[cfg(unix)]
702fn same_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
703    use std::os::unix::fs::MetadataExt as _;
704    left.dev() == right.dev() && left.ino() == right.ino()
705}
706
707#[cfg(not(unix))]
708fn same_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
709    left.file_type() == right.file_type()
710        && left.len() == right.len()
711        && left.modified().ok() == right.modified().ok()
712}
713
714fn validate_regular_directory_path(path: &Path) -> std::io::Result<()> {
715    // Reject a link at the security boundary itself, but resolve platform
716    // aliases above it before inspecting ancestors. macOS normally places
717    // temporary directories below `/var` -> `/private/var`, while Windows can
718    // canonicalize an 8.3 path to its long spelling. Neither changes the
719    // identity or containment of the managed directory tree.
720    validate_regular_directory(path)?;
721    let canonical = dunce::canonicalize(path)?;
722    let mut current = PathBuf::new();
723    for component in canonical.components() {
724        current.push(component.as_os_str());
725        match component {
726            std::path::Component::RootDir | std::path::Component::Prefix(_) => continue,
727            std::path::Component::Normal(_) => validate_regular_directory(&current)?,
728            _ => {
729                return Err(std::io::Error::other(format!(
730                    "inventory path contains a non-canonical component: {}",
731                    canonical.display()
732                )));
733            }
734        }
735    }
736    Ok(())
737}
738
739fn validate_regular_directory_path_from(base: &Path, path: &Path) -> std::io::Result<()> {
740    let relative = path.strip_prefix(base).map_err(|_| {
741        std::io::Error::other(format!(
742            "{} is outside inventory root {}",
743            path.display(),
744            base.display()
745        ))
746    })?;
747    validate_regular_directory(base)?;
748    let mut current = base.to_path_buf();
749    for component in relative.components() {
750        match component {
751            std::path::Component::Normal(component) => current.push(component),
752            _ => {
753                return Err(std::io::Error::other(format!(
754                    "inventory path contains a non-canonical component: {}",
755                    path.display()
756                )));
757            }
758        }
759        validate_regular_directory(&current)?;
760    }
761    Ok(())
762}
763
764fn validate_regular_directory(path: &Path) -> std::io::Result<()> {
765    let metadata = std::fs::symlink_metadata(path)?;
766    if metadata.file_type().is_symlink() || !metadata.is_dir() {
767        return Err(std::io::Error::other(format!(
768            "inventory path component must be a non-symlink directory: {}",
769            path.display()
770        )));
771    }
772    Ok(())
773}
774
775fn normalize_bins(bins: &mut [DynamicToolBin]) -> Result<()> {
776    for bin in bins.iter_mut() {
777        bin.name = normalize_bin_name(&bin.name)?;
778        bin.path = normalize_relative_bin_path(&bin.path)?;
779    }
780    bins.sort_by(|left, right| {
781        (left.name.as_str(), left.path.as_str()).cmp(&(right.name.as_str(), right.path.as_str()))
782    });
783    for pair in bins.windows(2) {
784        if pair[0].name == pair[1].name {
785            return Err(Error::config(format!(
786                "duplicate dynamic tool bin `{}`",
787                pair[0].name
788            )));
789        }
790    }
791    Ok(())
792}
793
794fn normalize_bin_name(value: &str) -> Result<String> {
795    let value = value.trim();
796    if value.is_empty()
797        || value == "."
798        || value == ".."
799        || value.contains('/')
800        || value.contains('\\')
801        || value.contains(':')
802    {
803        return Err(Error::config(format!(
804            "dynamic tool bin name must be a single filename: `{value}`"
805        )));
806    }
807    Ok(value.to_string())
808}
809
810fn normalize_relative_bin_path(value: &str) -> Result<String> {
811    let value = value.trim();
812    if value.is_empty() {
813        return Err(Error::config("dynamic tool bin path must not be empty"));
814    }
815    if value.contains(':') {
816        return Err(Error::config(format!(
817            "dynamic tool bin path must stay inside the install root: `{value}`"
818        )));
819    }
820    let normalized = value.replace('\\', "/");
821    if normalized.starts_with('/') {
822        return Err(Error::config(format!(
823            "dynamic tool bin path must stay inside the install root: `{value}`"
824        )));
825    }
826
827    let mut parts = Vec::new();
828    for part in normalized.split('/') {
829        if part.is_empty() || part == "." || part == ".." {
830            return Err(Error::config(format!(
831                "dynamic tool bin path must stay inside the install root: `{value}`"
832            )));
833        }
834        parts.push(part);
835    }
836    Ok(parts.join("/"))
837}
838
839fn deduped_ids<'a>(values: impl IntoIterator<Item = &'a str>) -> Vec<String> {
840    values
841        .into_iter()
842        .filter_map(extract_dynamic_id_from_value)
843        .collect::<BTreeSet<_>>()
844        .into_iter()
845        .collect()
846}
847
848fn extract_dynamic_id_from_value(value: &str) -> Option<String> {
849    let request = ToolRequest::parse(value).ok()?;
850    request
851        .backend
852        .contains(':')
853        .then_some(request.backend)
854        .and_then(|backend| canonical_dynamic_id(&backend).ok())
855}
856
857fn handle_scan_problem(
858    diagnostics: &mut Vec<InventoryDiagnostic>,
859    options: &ScanOptions,
860    path: PathBuf,
861    kind: InventoryDiagnosticKind,
862    message: String,
863) -> Result<()> {
864    match options.corrupt_manifest_policy {
865        CorruptManifestPolicy::FailClosed => Err(Error::other(format!(
866            "refusing dynamic tool inventory scan because {} at {}",
867            message,
868            path.display()
869        ))),
870        CorruptManifestPolicy::CollectDiagnostics => {
871            diagnostics.push(InventoryDiagnostic {
872                path,
873                kind,
874                message,
875            });
876            Ok(())
877        }
878    }
879}
880
881fn path_sort_key(path: &Path) -> String {
882    path.to_string_lossy().replace('\\', "/")
883}
884
885fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
886    let parent = path
887        .parent()
888        .ok_or_else(|| Error::other(format!("path has no parent: {}", path.display())))?;
889    std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
890
891    let bytes = serde_json::to_vec_pretty(value)?;
892    let temporary = unique_temporary_path(parent, path.file_name().unwrap_or_default());
893    {
894        let mut file = std::fs::OpenOptions::new()
895            .create_new(true)
896            .write(true)
897            .open(&temporary)
898            .map_err(|error| Error::io(&temporary, error))?;
899        file.write_all(&bytes)
900            .map_err(|error| Error::io(&temporary, error))?;
901        file.sync_all()
902            .map_err(|error| Error::io(&temporary, error))?;
903    }
904    if let Err(error) = atomic_replace(&temporary, path) {
905        let _ = std::fs::remove_file(&temporary);
906        return Err(error);
907    }
908    Ok(())
909}
910
911fn unique_temporary_path(parent: &Path, file_name: &std::ffi::OsStr) -> PathBuf {
912    let file_name = file_name.to_string_lossy();
913    for attempt in 0..1024u32 {
914        let candidate = parent.join(format!(
915            ".{file_name}.tmp-{}-{}",
916            std::process::id(),
917            attempt
918        ));
919        if !candidate.exists() {
920            return candidate;
921        }
922    }
923    parent.join(format!(".{file_name}.tmp-{}-fallback", std::process::id()))
924}
925
926#[cfg(not(windows))]
927fn atomic_replace(source: &Path, destination: &Path) -> Result<()> {
928    std::fs::rename(source, destination).map_err(|error| Error::io(destination, error))
929}
930
931#[cfg(windows)]
932fn atomic_replace(source: &Path, destination: &Path) -> Result<()> {
933    use std::os::windows::ffi::OsStrExt;
934    use windows_sys::Win32::Storage::FileSystem::{
935        MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
936    };
937
938    let source_wide: Vec<u16> = source.as_os_str().encode_wide().chain(Some(0)).collect();
939    let destination_wide: Vec<u16> = destination
940        .as_os_str()
941        .encode_wide()
942        .chain(Some(0))
943        .collect();
944    let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH;
945    let result = unsafe { MoveFileExW(source_wide.as_ptr(), destination_wide.as_ptr(), flags) };
946    if result == 0 {
947        return Err(Error::io(destination, std::io::Error::last_os_error()));
948    }
949    Ok(())
950}
951
952#[cfg(test)]
953mod install_manifest_tests {
954    use super::*;
955    use crate::tool::{InstallDependency, InstallDependencyKind, InstallIdentity, InstallScope};
956
957    fn identity() -> InstallIdentity {
958        InstallIdentity::new(
959            "npm:Prettier",
960            "3.6.2",
961            "linux-x64",
962            InstallScope::Isolated,
963            &BTreeMap::from([("installer".into(), "AUBE".into())]),
964            vec![InstallDependency {
965                kind: InstallDependencyKind::Runtime,
966                id: "node".into(),
967                version: "24.1.0".into(),
968                identity: None,
969            }],
970            BTreeMap::from([("root-sri".into(), "sha512-example".into())]),
971        )
972        .unwrap()
973    }
974
975    #[test]
976    fn new_manifest_round_trips_and_rejects_unknown_or_tampered_identity() {
977        let temporary = tempfile::tempdir().unwrap();
978        let mut manifest = DynamicToolManifest::from_identity(identity()).unwrap();
979        manifest.bins.push(DynamicToolBin {
980            name: "prettier".into(),
981            path: "bin/prettier".into(),
982        });
983        manifest.write_atomic(temporary.path()).unwrap();
984        let loaded = DynamicToolManifest::load(temporary.path()).unwrap();
985        assert_eq!(loaded, manifest.normalize().unwrap());
986        assert!(loaded.matches_identity(&identity()));
987
988        let path = DynamicToolManifest::manifest_path(temporary.path());
989        let mut json: serde_json::Value =
990            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
991        json["identity"]["version"] = "3.6.3".into();
992        assert!(DynamicToolManifest::from_slice(&serde_json::to_vec(&json).unwrap()).is_err());
993        json["unknown"] = true.into();
994        assert!(DynamicToolManifest::from_slice(&serde_json::to_vec(&json).unwrap()).is_err());
995    }
996
997    #[test]
998    fn legacy_files_are_detected_but_never_parsed_as_installs() {
999        let temporary = tempfile::tempdir().unwrap();
1000        let legacy = temporary.path().join("legacy");
1001        std::fs::create_dir_all(&legacy).unwrap();
1002        std::fs::write(legacy.join(LEGACY_INVENTORY_FILE), b"not even json").unwrap();
1003
1004        let current_identity = identity();
1005        let current = temporary
1006            .path()
1007            .join(crate::dirs::sanitize_tool_id(&current_identity.tool))
1008            .join(crate::dirs::sanitize_version_component(
1009                &current_identity.version,
1010            ))
1011            .join(crate::dirs::install_id_component(&current_identity.install_id).unwrap());
1012        DynamicToolManifest::from_identity(current_identity)
1013            .unwrap()
1014            .write_atomic(&current)
1015            .unwrap();
1016        let report = scan_installs(temporary.path(), &ScanOptions::default()).unwrap();
1017        assert_eq!(report.installs.len(), 1);
1018        assert_eq!(report.legacy_installs.len(), 1);
1019        assert!(report.diagnostics.is_empty());
1020        assert_eq!(report.legacy_installs[0].install_root, legacy);
1021    }
1022
1023    #[test]
1024    fn bins_are_normalized_and_confined() {
1025        let mut manifest = DynamicToolManifest::from_identity(identity()).unwrap();
1026        manifest.bins.push(DynamicToolBin {
1027            name: " prettier ".into(),
1028            path: r"bin\prettier".into(),
1029        });
1030        assert_eq!(manifest.normalize().unwrap().bins[0].path, "bin/prettier");
1031
1032        let mut escaped = DynamicToolManifest::from_identity(identity()).unwrap();
1033        escaped.bins.push(DynamicToolBin {
1034            name: "prettier".into(),
1035            path: "../prettier".into(),
1036        });
1037        assert!(escaped.normalize().is_err());
1038    }
1039
1040    #[test]
1041    fn scan_rejects_current_manifest_at_wrong_identity_root() {
1042        let temporary = tempfile::tempdir().unwrap();
1043        DynamicToolManifest::from_identity(identity())
1044            .unwrap()
1045            .write_atomic(&temporary.path().join("wrong"))
1046            .unwrap();
1047        assert!(scan_installs(temporary.path(), &ScanOptions::default()).is_err());
1048        let report = scan_installs(
1049            temporary.path(),
1050            &ScanOptions {
1051                corrupt_manifest_policy: CorruptManifestPolicy::CollectDiagnostics,
1052                ..ScanOptions::default()
1053            },
1054        )
1055        .unwrap();
1056        assert!(report.installs.is_empty());
1057        assert_eq!(report.diagnostics.len(), 1);
1058    }
1059
1060    #[test]
1061    fn scan_accepts_only_the_identity_derived_root() {
1062        let temporary = tempfile::tempdir().unwrap();
1063        let manifest_identity = identity();
1064        let root = temporary
1065            .path()
1066            .join(crate::dirs::sanitize_tool_id(&manifest_identity.tool))
1067            .join(crate::dirs::sanitize_version_component(
1068                &manifest_identity.version,
1069            ))
1070            .join(crate::dirs::install_id_component(&manifest_identity.install_id).unwrap());
1071        DynamicToolManifest::from_identity(manifest_identity.clone())
1072            .unwrap()
1073            .write_atomic(&root)
1074            .unwrap();
1075
1076        let report = scan_installs(temporary.path(), &ScanOptions::default()).unwrap();
1077        assert_eq!(report.installs.len(), 1);
1078        assert_eq!(report.installs[0].install_root, root);
1079        assert_eq!(report.installs[0].manifest.identity, manifest_identity);
1080    }
1081
1082    #[test]
1083    fn load_rejects_a_non_regular_manifest() {
1084        let temporary = tempfile::tempdir().unwrap();
1085        let manifest_path = DynamicToolManifest::manifest_path(temporary.path());
1086        std::fs::create_dir(&manifest_path).unwrap();
1087        assert!(DynamicToolManifest::load(temporary.path()).is_err());
1088    }
1089
1090    #[test]
1091    fn scan_ignores_hidden_transaction_directories() {
1092        let temporary = tempfile::tempdir().unwrap();
1093        DynamicToolManifest::from_identity(identity())
1094            .unwrap()
1095            .write_atomic(&temporary.path().join("tool/.stage"))
1096            .unwrap();
1097        let report = scan_installs(temporary.path(), &ScanOptions::default()).unwrap();
1098        assert!(report.installs.is_empty());
1099    }
1100
1101    #[cfg(unix)]
1102    #[test]
1103    fn scan_does_not_follow_manifest_or_directory_symlinks() {
1104        use std::os::unix::fs::symlink;
1105
1106        let temporary = tempfile::tempdir().unwrap();
1107        let outside = tempfile::tempdir().unwrap();
1108        DynamicToolManifest::from_identity(identity())
1109            .unwrap()
1110            .write_atomic(outside.path())
1111            .unwrap();
1112        symlink(outside.path(), temporary.path().join("linked-dir")).unwrap();
1113        symlink(
1114            DynamicToolManifest::manifest_path(outside.path()),
1115            temporary.path().join(INVENTORY_FILE),
1116        )
1117        .unwrap();
1118        let report = scan_installs(temporary.path(), &ScanOptions::default()).unwrap();
1119        assert!(report.installs.is_empty());
1120    }
1121
1122    #[cfg(unix)]
1123    #[test]
1124    fn scan_rejects_a_symlinked_scan_root() {
1125        use std::os::unix::fs::symlink;
1126
1127        let temporary = tempfile::tempdir().unwrap();
1128        let real = temporary.path().join("real");
1129        std::fs::create_dir(&real).unwrap();
1130        let linked = temporary.path().join("linked");
1131        symlink(&real, &linked).unwrap();
1132
1133        let error = scan_installs(&linked, &ScanOptions::default()).unwrap_err();
1134        assert!(error.to_string().contains("non-symlink directory"));
1135    }
1136
1137    #[cfg(unix)]
1138    #[test]
1139    fn scan_accepts_a_real_root_below_a_symlinked_platform_ancestor() {
1140        use std::os::unix::fs::symlink;
1141
1142        let temporary = tempfile::tempdir().unwrap();
1143        let real_parent = temporary.path().join("real");
1144        std::fs::create_dir(&real_parent).unwrap();
1145        let alias = temporary.path().join("alias");
1146        symlink(&real_parent, &alias).unwrap();
1147        let scan_root = alias.join("installs");
1148
1149        let manifest_identity = identity();
1150        let root = scan_root
1151            .join(crate::dirs::sanitize_tool_id(&manifest_identity.tool))
1152            .join(crate::dirs::sanitize_version_component(
1153                &manifest_identity.version,
1154            ))
1155            .join(crate::dirs::install_id_component(&manifest_identity.install_id).unwrap());
1156        DynamicToolManifest::from_identity(manifest_identity)
1157            .unwrap()
1158            .write_atomic(&root)
1159            .unwrap();
1160
1161        assert!(DynamicToolManifest::load(&root).is_ok());
1162        let report = scan_installs(&scan_root, &ScanOptions::default()).unwrap();
1163        assert_eq!(report.installs.len(), 1);
1164        assert_eq!(report.installs[0].install_root, root);
1165    }
1166
1167    #[cfg(unix)]
1168    #[test]
1169    fn load_rejects_a_symlinked_manifest() {
1170        use std::os::unix::fs::symlink;
1171
1172        let temporary = tempfile::tempdir().unwrap();
1173        let outside = tempfile::tempdir().unwrap();
1174        DynamicToolManifest::from_identity(identity())
1175            .unwrap()
1176            .write_atomic(outside.path())
1177            .unwrap();
1178        symlink(
1179            DynamicToolManifest::manifest_path(outside.path()),
1180            DynamicToolManifest::manifest_path(temporary.path()),
1181        )
1182        .unwrap();
1183
1184        assert!(DynamicToolManifest::load(temporary.path()).is_err());
1185    }
1186
1187    #[cfg(unix)]
1188    #[test]
1189    fn scan_rejects_a_symlinked_identity_component() {
1190        use std::os::unix::fs::symlink;
1191
1192        let temporary = tempfile::tempdir().unwrap();
1193        let outside = tempfile::tempdir().unwrap();
1194        let manifest_identity = identity();
1195        let relative_root = crate::dirs::sanitize_tool_id(&manifest_identity.tool)
1196            .join(crate::dirs::sanitize_version_component(
1197                &manifest_identity.version,
1198            ))
1199            .join(crate::dirs::install_id_component(&manifest_identity.install_id).unwrap());
1200        let outside_root = outside.path().join(&relative_root);
1201        DynamicToolManifest::from_identity(manifest_identity)
1202            .unwrap()
1203            .write_atomic(&outside_root)
1204            .unwrap();
1205
1206        let linked_tool = temporary.path().join("npm");
1207        symlink(outside.path().join("npm"), &linked_tool).unwrap();
1208        let report = scan_installs(
1209            temporary.path(),
1210            &ScanOptions {
1211                corrupt_manifest_policy: CorruptManifestPolicy::CollectDiagnostics,
1212                ..ScanOptions::default()
1213            },
1214        )
1215        .unwrap();
1216        assert!(report.installs.is_empty());
1217        assert!(report.diagnostics.is_empty());
1218    }
1219
1220    #[cfg(unix)]
1221    #[test]
1222    fn scanned_install_rejects_a_changed_manifest_path() {
1223        let temporary = tempfile::tempdir().unwrap();
1224        let manifest_identity = identity();
1225        let root = temporary
1226            .path()
1227            .join(crate::dirs::sanitize_tool_id(&manifest_identity.tool))
1228            .join(crate::dirs::sanitize_version_component(
1229                &manifest_identity.version,
1230            ))
1231            .join(crate::dirs::install_id_component(&manifest_identity.install_id).unwrap());
1232        DynamicToolManifest::from_identity(manifest_identity)
1233            .unwrap()
1234            .write_atomic(&root)
1235            .unwrap();
1236        let report = scan_installs(temporary.path(), &ScanOptions::default()).unwrap();
1237        report.installs[0].revalidate().unwrap();
1238
1239        let path = DynamicToolManifest::manifest_path(&root);
1240        let replacement = root.join("replacement");
1241        std::fs::write(&replacement, std::fs::read(&path).unwrap()).unwrap();
1242        std::fs::rename(&replacement, &path).unwrap();
1243
1244        assert!(report.installs[0].revalidate().is_err());
1245    }
1246
1247    #[cfg(unix)]
1248    #[test]
1249    fn scanned_install_rejects_a_changed_root_path() {
1250        use std::os::unix::fs::symlink;
1251
1252        let temporary = tempfile::tempdir().unwrap();
1253        let manifest_identity = identity();
1254        let root = temporary
1255            .path()
1256            .join(crate::dirs::sanitize_tool_id(&manifest_identity.tool))
1257            .join(crate::dirs::sanitize_version_component(
1258                &manifest_identity.version,
1259            ))
1260            .join(crate::dirs::install_id_component(&manifest_identity.install_id).unwrap());
1261        DynamicToolManifest::from_identity(manifest_identity)
1262            .unwrap()
1263            .write_atomic(&root)
1264            .unwrap();
1265        let report = scan_installs(temporary.path(), &ScanOptions::default()).unwrap();
1266        let moved = temporary.path().join("moved");
1267        std::fs::rename(&root, &moved).unwrap();
1268        symlink(&moved, &root).unwrap();
1269
1270        assert!(report.installs[0].revalidate().is_err());
1271    }
1272}