1use 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
18pub const INVENTORY_FILE: &str = ".osdk-install.json";
20pub 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 pub name: String,
33 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 #[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 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 FailClosed,
142 CollectDiagnostics,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct ScanOptions {
148 pub max_depth: usize,
150 pub max_manifest_bytes: u64,
152 pub max_manifests: usize,
154 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 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 .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
643pub(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, ¤t_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 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(¤t)?,
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(¤t)?;
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(¤t_identity.tool))
1008 .join(crate::dirs::sanitize_version_component(
1009 ¤t_identity.version,
1010 ))
1011 .join(crate::dirs::install_id_component(¤t_identity.install_id).unwrap());
1012 DynamicToolManifest::from_identity(current_identity)
1013 .unwrap()
1014 .write_atomic(¤t)
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}