1use std::collections::{BTreeMap, BTreeSet};
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15
16use serde::{Deserialize, Serialize};
17
18use crate::dirs::{Dirs, InstallLocator};
19use crate::error::{Error, Result};
20use crate::inventory::{DynamicToolBin, DynamicToolManifest, ScanOptions};
21use crate::pipeline::HashAlgo;
22use crate::platform::Platform;
23use crate::tool::{InstallDependency, InstallDependencyKind, InstallIdentity, InstallScope};
24
25pub const NATIVE_TOOL_RECEIPT_FILE: &str = ".osdk-native-receipt.json";
26pub const LOCKED_NATIVE_RUNTIME_OPTION: &str = "__osdk_native_runtime";
27pub const LOCKED_NATIVE_RUNTIME_VERSION_OPTION: &str = "__osdk_native_runtime_version";
28pub const LOCKED_NATIVE_REPLAY_OPTION: &str = "__osdk_native_replay";
29const NATIVE_TOOL_RECEIPT_SCHEMA: u32 = 1;
30const MAX_NATIVE_TOOL_RECEIPT_BYTES: u64 = 256 * 1024;
31const NATIVE_TOOL_SEAL_SUFFIX: &str = ".native-seal";
32const RUST_RUNTIME_RECEIPT_FILE: &str = ".osdk-rust-runtime-receipt.json";
33const RUST_RUNTIME_RECEIPT_SCHEMA: u32 = 1;
34const MAX_RUST_RUNTIME_RECEIPT_BYTES: u64 = 8 * 1024 * 1024;
35const GO_RUNTIME_RECEIPT_FILE: &str = ".osdk-go-runtime-receipt.json";
36const GO_RUNTIME_RECEIPT_SCHEMA: u32 = 1;
37const MAX_GO_RUNTIME_RECEIPT_BYTES: u64 = 8 * 1024 * 1024;
38const MAX_GO_RUNTIME_FILES: usize = 65_536;
39const MAX_GO_RUNTIME_PATH_BYTES: usize = 4 * 1024;
40const MAX_RUSTC_MANIFEST_BYTES: u64 = 4 * 1024 * 1024;
41const MAX_RUST_RUNTIME_FILES: usize = 65_536;
42const MAX_RUST_RUNTIME_PATH_BYTES: usize = 4 * 1024;
43static NEXT_STAGE: AtomicU64 = AtomicU64::new(0);
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct NativeToolBinReceipt {
48 pub path: String,
50 pub size: u64,
51 pub sha256: String,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct NativeToolReceipt {
57 pub schema: u32,
58 pub provider: NativeToolProvider,
60 pub runtime: InstallDependency,
62 pub bins: Vec<NativeToolBinReceipt>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68struct NativeToolSeal {
69 schema: u32,
70 install_id: String,
71 content_blake3: String,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "kebab-case")]
76pub enum NativeToolProvider {
77 CargoInstall,
78 CargoBinstall,
79 GoInstall,
80}
81
82const CARGO_PROVIDERS: &[NativeToolProvider] = &[
83 NativeToolProvider::CargoInstall,
84 NativeToolProvider::CargoBinstall,
85];
86const GO_PROVIDERS: &[NativeToolProvider] = &[NativeToolProvider::GoInstall];
87
88pub fn runtime_tree_identity(root: &Path) -> Result<String> {
92 hash_runtime_tree(root)
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97struct GoRuntimeFileReceipt {
98 path: String,
99 size: u64,
100 modified_seconds: i64,
101 modified_nanoseconds: u32,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 symlink_target: Option<String>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(deny_unknown_fields)]
108struct GoRuntimeReceipt {
109 schema: u32,
110 version: String,
111 platform: String,
112 files: Vec<GoRuntimeFileReceipt>,
113 identity: String,
114 integrity_blake3: String,
115}
116
117#[derive(Debug)]
118struct GoRuntimeFile {
119 receipt: GoRuntimeFileReceipt,
120 absolute: PathBuf,
121}
122
123pub fn go_runtime_identity(dirs: &Dirs, platform: Platform, version: &str) -> Result<String> {
133 let root = dirs.install_path("go", version);
134 validate_managed_go_root(&root, platform, version)?;
135 let _lock = crate::lock::FileLock::acquire(go_runtime_receipt_lock_path(dirs, version))?;
136 validate_managed_go_root(&root, platform, version)?;
137
138 let files = collect_go_runtime_files(&root, &dirs.store, platform)?;
139 let receipt_path = root.join(GO_RUNTIME_RECEIPT_FILE);
140 match std::fs::symlink_metadata(&receipt_path) {
141 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
142 Err(error) => return Err(Error::io(&receipt_path, error)),
143 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
144 return Err(Error::other(format!(
145 "managed Go runtime receipt is not a regular non-symlink file: {}",
146 receipt_path.display()
147 )));
148 }
149 Ok(_) => {
150 let receipt = load_go_runtime_receipt(&receipt_path)?;
151 validate_go_runtime_receipt(&receipt, version, &platform.to_string())?;
152 let current = files
153 .iter()
154 .map(|file| file.receipt.clone())
155 .collect::<Vec<_>>();
156 if receipt.files == current {
157 return Ok(receipt.identity);
158 }
159 }
160 }
161
162 let platform_name = platform.to_string();
163 let identity = hash_go_runtime_files(version, &platform_name, &files)?;
164 let after = collect_go_runtime_files(&root, &dirs.store, platform)?;
165 let before_metadata = files
166 .iter()
167 .map(|file| file.receipt.clone())
168 .collect::<Vec<_>>();
169 let after_metadata = after
170 .iter()
171 .map(|file| file.receipt.clone())
172 .collect::<Vec<_>>();
173 if before_metadata != after_metadata {
174 return Err(Error::other(format!(
175 "managed Go runtime changed while its identity was being computed: {}",
176 root.display()
177 )));
178 }
179 validate_managed_go_root(&root, platform, version)?;
180
181 let mut receipt = GoRuntimeReceipt {
182 schema: GO_RUNTIME_RECEIPT_SCHEMA,
183 version: version.to_string(),
184 platform: platform_name,
185 files: before_metadata,
186 identity: identity.clone(),
187 integrity_blake3: String::new(),
188 };
189 receipt.integrity_blake3 = go_runtime_receipt_integrity(&receipt);
190 write_go_runtime_receipt_atomic(&receipt_path, &receipt)?;
191 Ok(identity)
192}
193
194fn validate_managed_go_root(root: &Path, platform: Platform, version: &str) -> Result<()> {
195 let metadata = std::fs::symlink_metadata(root).map_err(|error| Error::io(root, error))?;
196 if metadata.file_type().is_symlink() || !metadata.is_dir() {
197 return Err(Error::other(format!(
198 "managed Go runtime root is not a regular directory: {}",
199 root.display()
200 )));
201 }
202 if !is_regular_file(&root.join(".osdk-complete")) {
203 return Err(Error::config(format!(
204 "Go runtime `{version}` is not a complete osdk-managed runtime"
205 )));
206 }
207 for name in ["go", "gofmt"] {
208 let path = root
209 .join("bin")
210 .join(format!("{name}{}", platform.os.exe_suffix()));
211 if !std::fs::metadata(&path).is_ok_and(|metadata| metadata.is_file()) {
212 return Err(Error::other(format!(
213 "managed Go runtime `{version}` is missing {}",
214 path.display()
215 )));
216 }
217 }
218 Ok(())
219}
220
221fn go_runtime_receipt_lock_path(dirs: &Dirs, version: &str) -> PathBuf {
222 let mut hasher = blake3::Hasher::new_derive_key("osdk-go-runtime-receipt-lock-v1");
223 hash_identity_value(&mut hasher, version.as_bytes());
224 dirs.lock_dir("go")
225 .join(format!("runtime-{}.lock", hasher.finalize().to_hex()))
226}
227
228fn collect_go_runtime_files(
229 root: &Path,
230 store: &Path,
231 platform: Platform,
232) -> Result<Vec<GoRuntimeFile>> {
233 let mut paths = BTreeMap::<String, PathBuf>::new();
234 for name in ["VERSION", "go.env"] {
235 let path = root.join(name);
236 if name == "VERSION" || path.exists() {
237 insert_go_runtime_file(root, store, &path, &mut paths)?;
238 }
239 }
240 for directory in ["bin", "pkg", "src"] {
241 collect_go_runtime_tree(root, store, &root.join(directory), &mut paths, true)?;
242 }
243 for directory in ["lib", "misc"] {
244 let path = root.join(directory);
245 if path.exists() {
246 collect_go_runtime_tree(root, store, &path, &mut paths, false)?;
247 }
248 }
249 for name in ["go", "gofmt"] {
250 insert_go_runtime_file(
251 root,
252 store,
253 &root
254 .join("bin")
255 .join(format!("{name}{}", platform.os.exe_suffix())),
256 &mut paths,
257 )?;
258 }
259 if paths.len() > MAX_GO_RUNTIME_FILES {
260 return Err(Error::other(format!(
261 "managed Go identity exceeds the {MAX_GO_RUNTIME_FILES} file limit"
262 )));
263 }
264 paths
265 .into_iter()
266 .map(|(path, absolute)| {
267 let (metadata, symlink_target) = go_runtime_file_metadata(root, store, &absolute)?;
268 let (modified_seconds, modified_nanoseconds) = modified_parts(&metadata, &absolute)?;
269 Ok(GoRuntimeFile {
270 receipt: GoRuntimeFileReceipt {
271 path,
272 size: metadata.len(),
273 modified_seconds,
274 modified_nanoseconds,
275 symlink_target,
276 },
277 absolute,
278 })
279 })
280 .collect()
281}
282
283fn collect_go_runtime_tree(
284 root: &Path,
285 store: &Path,
286 directory: &Path,
287 paths: &mut BTreeMap<String, PathBuf>,
288 require_file: bool,
289) -> Result<()> {
290 let metadata =
291 std::fs::symlink_metadata(directory).map_err(|error| Error::io(directory, error))?;
292 if metadata.file_type().is_symlink() || !metadata.is_dir() {
293 return Err(Error::other(format!(
294 "managed Go identity directory is unsafe: {}",
295 directory.display()
296 )));
297 }
298 let mut found_file = false;
299 for entry in walkdir::WalkDir::new(directory).follow_links(false) {
300 let entry = entry.map_err(|error| Error::other(format!("walkdir: {error}")))?;
301 if entry.file_type().is_symlink() {
302 go_runtime_file_metadata(root, store, entry.path())?;
303 insert_go_runtime_file(root, store, entry.path(), paths)?;
304 found_file = true;
305 continue;
306 }
307 if entry.file_type().is_dir() {
308 continue;
309 }
310 if !entry.file_type().is_file() {
311 return Err(Error::other(format!(
312 "managed Go identity contains a non-regular payload: {}",
313 entry.path().display()
314 )));
315 }
316 insert_go_runtime_file(root, store, entry.path(), paths)?;
317 found_file = true;
318 if paths.len() > MAX_GO_RUNTIME_FILES {
319 return Err(Error::other(format!(
320 "managed Go identity exceeds the {MAX_GO_RUNTIME_FILES} file limit"
321 )));
322 }
323 }
324 if require_file && !found_file {
325 return Err(Error::other(format!(
326 "managed Go runtime directory is empty: {}",
327 directory.display()
328 )));
329 }
330 Ok(())
331}
332
333fn insert_go_runtime_file(
334 root: &Path,
335 store: &Path,
336 path: &Path,
337 paths: &mut BTreeMap<String, PathBuf>,
338) -> Result<()> {
339 go_runtime_file_metadata(root, store, path)?;
340 let relative = path.strip_prefix(root).map_err(|_| {
341 Error::other(format!(
342 "managed Go identity path escapes runtime: {}",
343 path.display()
344 ))
345 })?;
346 let relative = relative
347 .to_str()
348 .ok_or_else(|| Error::config("managed Go identity contains a non-UTF-8 filename"))?
349 .replace('\\', "/");
350 if relative.is_empty() || relative.len() > MAX_GO_RUNTIME_PATH_BYTES {
351 return Err(Error::config(
352 "managed Go identity contains an invalid relative path",
353 ));
354 }
355 paths.insert(relative, path.to_path_buf());
356 Ok(())
357}
358
359fn go_runtime_file_metadata(
360 root: &Path,
361 store: &Path,
362 path: &Path,
363) -> Result<(std::fs::Metadata, Option<String>)> {
364 let link_metadata = std::fs::symlink_metadata(path).map_err(|error| Error::io(path, error))?;
365 if !link_metadata.file_type().is_symlink() {
366 if !link_metadata.is_file() {
367 return Err(Error::other(format!(
368 "managed Go identity path is not a regular file: {}",
369 path.display()
370 )));
371 }
372 return Ok((link_metadata, None));
373 }
374
375 let target = std::fs::read_link(path).map_err(|error| Error::io(path, error))?;
376 let resolved = if target.is_absolute() {
377 target.clone()
378 } else {
379 path.parent()
380 .ok_or_else(|| Error::other("managed Go symlink has no parent"))?
381 .join(&target)
382 };
383 let canonical = dunce::canonicalize(&resolved).map_err(|error| Error::io(&resolved, error))?;
384 let inside_runtime = canonical.starts_with(root);
385 let inside_store = dunce::canonicalize(store)
386 .ok()
387 .is_some_and(|canonical_store| canonical.starts_with(canonical_store));
388 if !inside_runtime && !inside_store {
389 return Err(Error::other(format!(
390 "managed Go identity symlink escapes osdk-controlled storage: {}",
391 path.display()
392 )));
393 }
394 let metadata = std::fs::metadata(path).map_err(|error| Error::io(path, error))?;
395 if !metadata.is_file() {
396 return Err(Error::other(format!(
397 "managed Go identity symlink does not resolve to a regular file: {}",
398 path.display()
399 )));
400 }
401 let target = target
402 .to_str()
403 .ok_or_else(|| Error::config("managed Go identity contains a non-UTF-8 symlink"))?
404 .replace('\\', "/");
405 if target.len() > MAX_GO_RUNTIME_PATH_BYTES {
406 return Err(Error::config(
407 "managed Go identity contains an overlong symlink target",
408 ));
409 }
410 Ok((metadata, Some(target)))
411}
412
413fn hash_go_runtime_files(version: &str, platform: &str, files: &[GoRuntimeFile]) -> Result<String> {
414 let mut hasher = blake3::Hasher::new_derive_key("osdk-go-runtime-essential-v1");
415 hash_identity_value(&mut hasher, version.as_bytes());
416 hash_identity_value(&mut hasher, platform.as_bytes());
417 hash_identity_value(&mut hasher, &(files.len() as u64).to_le_bytes());
418 for file in files {
419 hash_identity_value(&mut hasher, file.receipt.path.as_bytes());
420 hash_identity_value(
421 &mut hasher,
422 file.receipt
423 .symlink_target
424 .as_deref()
425 .unwrap_or_default()
426 .as_bytes(),
427 );
428 let digest = crate::pipeline::verify::hash_file(&file.absolute, HashAlgo::Sha256)?;
429 hash_identity_value(&mut hasher, digest.as_bytes());
430 }
431 Ok(format!("b3-go-v1:{}", hasher.finalize().to_hex()))
432}
433
434fn go_runtime_receipt_integrity(receipt: &GoRuntimeReceipt) -> String {
435 let mut hasher = blake3::Hasher::new_derive_key("osdk-go-runtime-receipt-v1");
436 hash_identity_value(&mut hasher, &receipt.schema.to_le_bytes());
437 hash_identity_value(&mut hasher, receipt.version.as_bytes());
438 hash_identity_value(&mut hasher, receipt.platform.as_bytes());
439 hash_identity_value(&mut hasher, &(receipt.files.len() as u64).to_le_bytes());
440 for file in &receipt.files {
441 hash_identity_value(&mut hasher, file.path.as_bytes());
442 hash_identity_value(&mut hasher, &file.size.to_le_bytes());
443 hash_identity_value(&mut hasher, &file.modified_seconds.to_le_bytes());
444 hash_identity_value(&mut hasher, &file.modified_nanoseconds.to_le_bytes());
445 hash_identity_value(
446 &mut hasher,
447 file.symlink_target
448 .as_deref()
449 .unwrap_or_default()
450 .as_bytes(),
451 );
452 }
453 hash_identity_value(&mut hasher, receipt.identity.as_bytes());
454 hasher.finalize().to_hex().to_string()
455}
456
457fn load_go_runtime_receipt(path: &Path) -> Result<GoRuntimeReceipt> {
458 let bytes = crate::inventory::read_stable_regular_file(path, MAX_GO_RUNTIME_RECEIPT_BYTES)
459 .map_err(|error| Error::io(path, error))?;
460 let receipt: GoRuntimeReceipt = serde_json::from_slice(&bytes)?;
461 if receipt.schema != GO_RUNTIME_RECEIPT_SCHEMA
462 || receipt.files.is_empty()
463 || receipt.files.len() > MAX_GO_RUNTIME_FILES
464 || receipt
465 .identity
466 .strip_prefix("b3-go-v1:")
467 .is_none_or(|digest| {
468 digest.len() != 64
469 || !digest
470 .bytes()
471 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
472 })
473 || receipt.integrity_blake3 != go_runtime_receipt_integrity(&receipt)
474 {
475 return Err(Error::config(format!(
476 "managed Go runtime receipt is invalid at {}",
477 path.display()
478 )));
479 }
480 Ok(receipt)
481}
482
483fn validate_go_runtime_receipt(
484 receipt: &GoRuntimeReceipt,
485 version: &str,
486 platform: &str,
487) -> Result<()> {
488 if receipt.version != version || receipt.platform != platform {
489 return Err(Error::config(
490 "managed Go runtime receipt does not match the selected runtime",
491 ));
492 }
493 let mut previous = None;
494 for file in &receipt.files {
495 if file.path.is_empty()
496 || file.path.len() > MAX_GO_RUNTIME_PATH_BYTES
497 || file.modified_nanoseconds >= 1_000_000_000
498 || previous.is_some_and(|path: &str| path >= file.path.as_str())
499 || Path::new(&file.path).is_absolute()
500 || Path::new(&file.path)
501 .components()
502 .any(|component| !matches!(component, std::path::Component::Normal(_)))
503 || file
504 .symlink_target
505 .as_deref()
506 .is_some_and(|target| target.is_empty() || target.len() > MAX_GO_RUNTIME_PATH_BYTES)
507 {
508 return Err(Error::config(
509 "managed Go runtime receipt contains an invalid file inventory",
510 ));
511 }
512 previous = Some(file.path.as_str());
513 }
514 Ok(())
515}
516
517fn write_go_runtime_receipt_atomic(path: &Path, receipt: &GoRuntimeReceipt) -> Result<()> {
518 let bytes = serde_json::to_vec_pretty(receipt)?;
519 if bytes.len() as u64 > MAX_GO_RUNTIME_RECEIPT_BYTES {
520 return Err(Error::other(
521 "managed Go runtime receipt exceeds its size limit",
522 ));
523 }
524 let parent = path
525 .parent()
526 .ok_or_else(|| Error::other(format!("path has no parent: {}", path.display())))?;
527 let serial = NEXT_STAGE.fetch_add(1, Ordering::Relaxed);
528 let temporary = parent.join(format!(
529 ".{GO_RUNTIME_RECEIPT_FILE}.tmp-{}-{serial}",
530 std::process::id()
531 ));
532 let result = (|| {
533 let mut options = std::fs::OpenOptions::new();
534 options.create_new(true).write(true);
535 #[cfg(unix)]
536 {
537 use std::os::unix::fs::OpenOptionsExt as _;
538 options.mode(0o600);
539 }
540 let mut file = options
541 .open(&temporary)
542 .map_err(|error| Error::io(&temporary, error))?;
543 use std::io::Write as _;
544 file.write_all(&bytes)
545 .map_err(|error| Error::io(&temporary, error))?;
546 file.sync_all()
547 .map_err(|error| Error::io(&temporary, error))?;
548 atomic_replace_runtime_receipt(&temporary, path)
549 })();
550 if result.is_err() {
551 let _ = std::fs::remove_file(&temporary);
552 }
553 result
554}
555
556#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
557#[serde(deny_unknown_fields)]
558struct RustRuntimeFileReceipt {
559 path: String,
560 size: u64,
561 modified_seconds: i64,
562 modified_nanoseconds: u32,
563}
564
565#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
566#[serde(deny_unknown_fields)]
567struct RustRuntimeReceipt {
568 schema: u32,
569 version: String,
570 platform: String,
571 toolchain_root: String,
572 files: Vec<RustRuntimeFileReceipt>,
573 identity: String,
574 integrity_blake3: String,
575}
576
577#[derive(Debug)]
578struct RustRuntimeFile {
579 receipt: RustRuntimeFileReceipt,
580 absolute: PathBuf,
581}
582
583pub fn rust_runtime_identity(dirs: &Dirs, platform: Platform, version: &str) -> Result<String> {
596 let marker = dirs.install_path("rust", version);
597 validate_managed_rust_marker(&marker, version)?;
598 let lock_path = rust_runtime_receipt_lock_path(dirs, version);
599 let _lock = crate::lock::FileLock::acquire(&lock_path)?;
600 validate_managed_rust_marker(&marker, version)?;
601
602 let root =
603 crate::backend::rust::RustBackend::exact_toolchain_dir_for_dirs(dirs, platform, version)
604 .ok_or_else(|| Error::NotInstalled {
605 tool: "rust".into(),
606 version: version.into(),
607 })?;
608 let root_metadata =
609 std::fs::symlink_metadata(&root).map_err(|error| Error::io(&root, error))?;
610 if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
611 return Err(Error::other(format!(
612 "managed Rust toolchain root is not a regular directory: {}",
613 root.display()
614 )));
615 }
616 let canonical_root = dunce::canonicalize(&root).map_err(|error| Error::io(&root, error))?;
617 validate_rust_runtime_directory_path(&canonical_root, &canonical_root.join("bin"))?;
618 let root_name = canonical_root
619 .file_name()
620 .and_then(|name| name.to_str())
621 .ok_or_else(|| Error::config("managed Rust toolchain path is not valid UTF-8"))?;
622 let platform_name = platform.to_string();
623 let files = collect_rust_runtime_files(&canonical_root, platform)?;
624 let receipt_path = marker.join(RUST_RUNTIME_RECEIPT_FILE);
625 match std::fs::symlink_metadata(&receipt_path) {
626 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
627 Err(error) => return Err(Error::io(&receipt_path, error)),
628 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
629 return Err(Error::other(format!(
630 "managed Rust runtime receipt is not a regular non-symlink file: {}",
631 receipt_path.display()
632 )));
633 }
634 Ok(_) => {
635 let receipt = load_rust_runtime_receipt(&receipt_path)?;
636 validate_rust_runtime_receipt(&receipt, version, &platform_name, root_name)?;
637 let current = files
638 .iter()
639 .map(|file| file.receipt.clone())
640 .collect::<Vec<_>>();
641 if receipt.files == current {
642 return Ok(receipt.identity);
643 }
644 }
645 }
646
647 let identity = hash_rust_runtime_files(version, &platform_name, root_name, &files)?;
648 let after = collect_rust_runtime_files(&canonical_root, platform)?;
649 let before_metadata = files
650 .iter()
651 .map(|file| file.receipt.clone())
652 .collect::<Vec<_>>();
653 let after_metadata = after
654 .iter()
655 .map(|file| file.receipt.clone())
656 .collect::<Vec<_>>();
657 if before_metadata != after_metadata {
658 return Err(Error::other(format!(
659 "managed Rust runtime changed while its identity was being computed: {}",
660 canonical_root.display()
661 )));
662 }
663 validate_managed_rust_marker(&marker, version)?;
664
665 let mut receipt = RustRuntimeReceipt {
666 schema: RUST_RUNTIME_RECEIPT_SCHEMA,
667 version: version.to_string(),
668 platform: platform_name,
669 toolchain_root: root_name.to_string(),
670 files: before_metadata,
671 identity: identity.clone(),
672 integrity_blake3: String::new(),
673 };
674 receipt.integrity_blake3 = rust_runtime_receipt_integrity(&receipt);
675 write_rust_runtime_receipt_atomic(&receipt_path, &receipt)?;
676 Ok(identity)
677}
678
679fn validate_managed_rust_marker(marker: &Path, version: &str) -> Result<()> {
680 let metadata = std::fs::symlink_metadata(marker).map_err(|error| Error::io(marker, error))?;
681 if metadata.file_type().is_symlink() || !metadata.is_dir() {
682 return Err(Error::other(format!(
683 "managed Rust marker is not a regular directory: {}",
684 marker.display()
685 )));
686 }
687 if !is_regular_file(&marker.join(".osdk-complete"))
688 || std::fs::symlink_metadata(marker.join(".osdk-linked")).is_ok()
689 {
690 return Err(Error::config(format!(
691 "Rust runtime `{version}` is not a complete osdk-managed toolchain"
692 )));
693 }
694 Ok(())
695}
696
697fn rust_runtime_receipt_lock_path(dirs: &Dirs, version: &str) -> PathBuf {
698 let mut hasher = blake3::Hasher::new_derive_key("osdk-rust-runtime-receipt-lock-v1");
699 hash_identity_value(&mut hasher, version.as_bytes());
700 dirs.lock_dir("rust")
701 .join(format!("runtime-{}.lock", hasher.finalize().to_hex()))
702}
703
704fn collect_rust_runtime_files(
705 canonical_root: &Path,
706 platform: Platform,
707) -> Result<Vec<RustRuntimeFile>> {
708 let bin = canonical_root.join("bin");
709 validate_rust_runtime_directory_path(canonical_root, &bin)?;
710 let mut paths = BTreeMap::<String, PathBuf>::new();
711 for name in ["cargo", "rustc"] {
712 let path = bin.join(format!("{name}{}", platform.os.exe_suffix()));
713 insert_rust_runtime_file(canonical_root, &path, &mut paths)?;
714 }
715
716 let rustlib = canonical_root.join("lib/rustlib");
717 validate_rust_runtime_directory_path(canonical_root, &rustlib)?;
718 let rustc_manifest = find_rustc_component_manifest(&rustlib, platform)?;
719 if let Some(manifest) = rustc_manifest {
720 insert_rust_runtime_file(canonical_root, &manifest, &mut paths)?;
721 let bytes = crate::inventory::read_stable_regular_file(&manifest, MAX_RUSTC_MANIFEST_BYTES)
722 .map_err(|error| Error::io(&manifest, error))?;
723 let text = std::str::from_utf8(&bytes)
724 .map_err(|_| Error::config("managed Rust rustc manifest is not valid UTF-8"))?;
725 for line in text.lines() {
726 let relative = line.strip_prefix("file:").ok_or_else(|| {
727 Error::config(format!(
728 "managed Rust rustc manifest contains an unsupported entry: {line}"
729 ))
730 })?;
731 let path = checked_rust_manifest_path(canonical_root, relative)?;
732 insert_rust_runtime_file(canonical_root, &path, &mut paths)?;
733 }
734 } else {
735 for name in ["rustdoc", "clippy-driver"] {
738 let path = bin.join(format!("{name}{}", platform.os.exe_suffix()));
739 if path.exists() {
740 insert_rust_runtime_file(canonical_root, &path, &mut paths)?;
741 }
742 }
743 let lib = canonical_root.join("lib");
744 collect_rust_runtime_tree(canonical_root, &lib, &mut paths, false)?;
745 }
746
747 let target_lib = rustlib.join(platform.llvm_triple()).join("lib");
748 collect_rust_runtime_tree(canonical_root, &target_lib, &mut paths, true)?;
749 if paths.len() > MAX_RUST_RUNTIME_FILES {
750 return Err(Error::other(format!(
751 "managed Rust identity exceeds the {MAX_RUST_RUNTIME_FILES} file limit"
752 )));
753 }
754
755 paths
756 .into_iter()
757 .map(|(path, absolute)| {
758 let metadata = rust_runtime_file_metadata(&absolute)?;
759 let (modified_seconds, modified_nanoseconds) = modified_parts(&metadata, &absolute)?;
760 Ok(RustRuntimeFile {
761 receipt: RustRuntimeFileReceipt {
762 path,
763 size: metadata.len(),
764 modified_seconds,
765 modified_nanoseconds,
766 },
767 absolute,
768 })
769 })
770 .collect()
771}
772
773fn find_rustc_component_manifest(rustlib: &Path, platform: Platform) -> Result<Option<PathBuf>> {
774 let canonical_root = rustlib
775 .parent()
776 .and_then(Path::parent)
777 .ok_or_else(|| Error::other("managed Rust rustlib path has no toolchain root"))?;
778 let exact = rustlib.join(format!("manifest-rustc-{}", platform.llvm_triple()));
779 if exact.exists() {
780 validate_rust_runtime_file_path(canonical_root, &exact)?;
781 return Ok(Some(exact));
782 }
783 let mut matches = Vec::new();
784 for entry in std::fs::read_dir(rustlib).map_err(|error| Error::io(rustlib, error))? {
785 let entry = entry.map_err(|error| Error::io(rustlib, error))?;
786 let name = entry.file_name();
787 let Some(name) = name.to_str() else {
788 return Err(Error::config(
789 "managed Rust rustlib contains a non-UTF-8 filename",
790 ));
791 };
792 if name.starts_with("manifest-rustc-") {
793 validate_rust_runtime_file_path(canonical_root, &entry.path())?;
794 matches.push(entry.path());
795 }
796 }
797 matches.sort();
798 match matches.len() {
799 0 => Ok(None),
800 1 => Ok(matches.pop()),
801 _ => Err(Error::other(format!(
802 "managed Rust toolchain has no unambiguous rustc manifest for {}",
803 platform.llvm_triple()
804 ))),
805 }
806}
807
808fn checked_rust_manifest_path(canonical_root: &Path, value: &str) -> Result<PathBuf> {
809 if value.is_empty()
810 || value.len() > MAX_RUST_RUNTIME_PATH_BYTES
811 || value.contains('\\')
812 || value.contains(':')
813 || value.chars().any(char::is_control)
814 {
815 return Err(Error::config(
816 "managed Rust rustc manifest contains an invalid path",
817 ));
818 }
819 let relative = Path::new(value);
820 if relative.is_absolute()
821 || relative
822 .components()
823 .any(|component| !matches!(component, std::path::Component::Normal(_)))
824 {
825 return Err(Error::config(format!(
826 "managed Rust rustc manifest contains an unsafe path: {value}"
827 )));
828 }
829 Ok(canonical_root.join(relative))
830}
831
832fn collect_rust_runtime_tree(
833 canonical_root: &Path,
834 directory: &Path,
835 paths: &mut BTreeMap<String, PathBuf>,
836 require_file: bool,
837) -> Result<()> {
838 validate_rust_runtime_directory_path(canonical_root, directory)?;
839 let mut found_file = false;
840 for entry in walkdir::WalkDir::new(directory).follow_links(false) {
841 let entry = entry.map_err(|error| Error::other(format!("walkdir: {error}")))?;
842 if entry.file_type().is_symlink() {
843 return Err(Error::other(format!(
844 "managed Rust identity contains a forbidden symlink: {}",
845 entry.path().display()
846 )));
847 }
848 if entry.file_type().is_dir() {
849 continue;
850 }
851 if !entry.file_type().is_file() {
852 return Err(Error::other(format!(
853 "managed Rust identity contains a non-regular payload: {}",
854 entry.path().display()
855 )));
856 }
857 insert_rust_runtime_file(canonical_root, entry.path(), paths)?;
858 found_file = true;
859 if paths.len() > MAX_RUST_RUNTIME_FILES {
860 return Err(Error::other(format!(
861 "managed Rust identity exceeds the {MAX_RUST_RUNTIME_FILES} file limit"
862 )));
863 }
864 }
865 if require_file && !found_file {
866 return Err(Error::other(format!(
867 "managed Rust target library is empty: {}",
868 directory.display()
869 )));
870 }
871 Ok(())
872}
873
874fn insert_rust_runtime_file(
875 canonical_root: &Path,
876 path: &Path,
877 paths: &mut BTreeMap<String, PathBuf>,
878) -> Result<()> {
879 validate_rust_runtime_file_path(canonical_root, path)?;
880 let canonical = dunce::canonicalize(path).map_err(|error| Error::io(path, error))?;
881 let relative = canonical.strip_prefix(canonical_root).map_err(|_| {
882 Error::other(format!(
883 "managed Rust identity path escapes toolchain: {}",
884 path.display()
885 ))
886 })?;
887 let relative = relative
888 .to_str()
889 .ok_or_else(|| Error::config("managed Rust identity contains a non-UTF-8 filename"))?;
890 let portable = relative.replace('\\', "/");
891 if portable.is_empty() || portable.len() > MAX_RUST_RUNTIME_PATH_BYTES {
892 return Err(Error::config(
893 "managed Rust identity contains an invalid relative path",
894 ));
895 }
896 paths.insert(portable, canonical);
897 Ok(())
898}
899
900fn validate_rust_runtime_directory_path(canonical_root: &Path, path: &Path) -> Result<()> {
901 validate_rust_runtime_path(canonical_root, path, true).map(|_| ())
902}
903
904fn validate_rust_runtime_file_path(canonical_root: &Path, path: &Path) -> Result<()> {
905 validate_rust_runtime_path(canonical_root, path, false).map(|_| ())
906}
907
908fn validate_rust_runtime_path(
909 canonical_root: &Path,
910 path: &Path,
911 expect_directory: bool,
912) -> Result<std::fs::Metadata> {
913 let relative = path.strip_prefix(canonical_root).map_err(|_| {
914 Error::other(format!(
915 "managed Rust identity path escapes toolchain: {}",
916 path.display()
917 ))
918 })?;
919 let mut current = canonical_root.to_path_buf();
920 let components = relative.components().collect::<Vec<_>>();
921 if components.is_empty() {
922 return Err(Error::other("managed Rust identity path is empty"));
923 }
924 for (index, component) in components.iter().enumerate() {
925 let std::path::Component::Normal(component) = component else {
926 return Err(Error::other(format!(
927 "managed Rust identity path is not canonical: {}",
928 path.display()
929 )));
930 };
931 current.push(component);
932 let metadata =
933 std::fs::symlink_metadata(¤t).map_err(|error| Error::io(¤t, error))?;
934 if metadata.file_type().is_symlink() {
935 return Err(Error::other(format!(
936 "managed Rust identity contains a forbidden symlink: {}",
937 current.display()
938 )));
939 }
940 let final_component = index + 1 == components.len();
941 if !final_component && !metadata.is_dir() {
942 return Err(Error::other(format!(
943 "managed Rust identity path has a non-directory ancestor: {}",
944 current.display()
945 )));
946 }
947 if final_component {
948 let valid_kind = if expect_directory {
949 metadata.is_dir()
950 } else {
951 metadata.is_file()
952 };
953 if !valid_kind {
954 return Err(Error::other(format!(
955 "managed Rust identity path has the wrong file type: {}",
956 current.display()
957 )));
958 }
959 return Ok(metadata);
960 }
961 }
962 Err(Error::other("managed Rust identity path is empty"))
963}
964
965fn rust_runtime_file_metadata(path: &Path) -> Result<std::fs::Metadata> {
966 let metadata = std::fs::symlink_metadata(path).map_err(|error| Error::io(path, error))?;
967 if metadata.file_type().is_symlink() || !metadata.is_file() {
968 return Err(Error::other(format!(
969 "managed Rust identity path is not a regular non-symlink file: {}",
970 path.display()
971 )));
972 }
973 Ok(metadata)
974}
975
976fn modified_parts(metadata: &std::fs::Metadata, path: &Path) -> Result<(i64, u32)> {
977 let modified = metadata
978 .modified()
979 .map_err(|error| Error::io(path, error))?;
980 match modified.duration_since(std::time::UNIX_EPOCH) {
981 Ok(duration) => {
982 let seconds = i64::try_from(duration.as_secs())
983 .map_err(|_| Error::other("managed Rust file timestamp exceeds i64"))?;
984 Ok((seconds, duration.subsec_nanos()))
985 }
986 Err(error) => {
987 let duration = error.duration();
988 let seconds = i64::try_from(duration.as_secs())
989 .map_err(|_| Error::other("managed Rust file timestamp exceeds i64"))?;
990 Ok((
991 -seconds - i64::from(duration.subsec_nanos() != 0),
992 duration.subsec_nanos(),
993 ))
994 }
995 }
996}
997
998fn hash_rust_runtime_files(
999 version: &str,
1000 platform: &str,
1001 root_name: &str,
1002 files: &[RustRuntimeFile],
1003) -> Result<String> {
1004 let mut hasher = blake3::Hasher::new_derive_key("osdk-rust-runtime-essential-v2");
1005 hash_identity_value(&mut hasher, version.as_bytes());
1006 hash_identity_value(&mut hasher, platform.as_bytes());
1007 hash_identity_value(&mut hasher, root_name.as_bytes());
1008 hash_identity_value(&mut hasher, &(files.len() as u64).to_le_bytes());
1009 for file in files {
1010 hash_identity_value(&mut hasher, file.receipt.path.as_bytes());
1011 let digest = crate::pipeline::verify::hash_file(&file.absolute, HashAlgo::Sha256)?;
1012 hash_identity_value(&mut hasher, digest.as_bytes());
1013 }
1014 Ok(format!("b3-rust-v2:{}", hasher.finalize().to_hex()))
1015}
1016
1017fn rust_runtime_receipt_integrity(receipt: &RustRuntimeReceipt) -> String {
1018 let mut hasher = blake3::Hasher::new_derive_key("osdk-rust-runtime-receipt-v1");
1019 hash_identity_value(&mut hasher, &receipt.schema.to_le_bytes());
1020 hash_identity_value(&mut hasher, receipt.version.as_bytes());
1021 hash_identity_value(&mut hasher, receipt.platform.as_bytes());
1022 hash_identity_value(&mut hasher, receipt.toolchain_root.as_bytes());
1023 hash_identity_value(&mut hasher, &(receipt.files.len() as u64).to_le_bytes());
1024 for file in &receipt.files {
1025 hash_identity_value(&mut hasher, file.path.as_bytes());
1026 hash_identity_value(&mut hasher, &file.size.to_le_bytes());
1027 hash_identity_value(&mut hasher, &file.modified_seconds.to_le_bytes());
1028 hash_identity_value(&mut hasher, &file.modified_nanoseconds.to_le_bytes());
1029 }
1030 hash_identity_value(&mut hasher, receipt.identity.as_bytes());
1031 hasher.finalize().to_hex().to_string()
1032}
1033
1034fn load_rust_runtime_receipt(path: &Path) -> Result<RustRuntimeReceipt> {
1035 let bytes = crate::inventory::read_stable_regular_file(path, MAX_RUST_RUNTIME_RECEIPT_BYTES)
1036 .map_err(|error| Error::io(path, error))?;
1037 let receipt: RustRuntimeReceipt = serde_json::from_slice(&bytes)?;
1038 if receipt.schema != RUST_RUNTIME_RECEIPT_SCHEMA
1039 || receipt.files.is_empty()
1040 || receipt.files.len() > MAX_RUST_RUNTIME_FILES
1041 || receipt
1042 .identity
1043 .strip_prefix("b3-rust-v2:")
1044 .is_none_or(|digest| {
1045 digest.len() != 64
1046 || !digest
1047 .bytes()
1048 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
1049 })
1050 || receipt.integrity_blake3 != rust_runtime_receipt_integrity(&receipt)
1051 {
1052 return Err(Error::config(format!(
1053 "managed Rust runtime receipt is invalid at {}",
1054 path.display()
1055 )));
1056 }
1057 Ok(receipt)
1058}
1059
1060fn validate_rust_runtime_receipt(
1061 receipt: &RustRuntimeReceipt,
1062 version: &str,
1063 platform: &str,
1064 root_name: &str,
1065) -> Result<()> {
1066 if receipt.version != version
1067 || receipt.platform != platform
1068 || receipt.toolchain_root != root_name
1069 {
1070 return Err(Error::config(
1071 "managed Rust runtime receipt does not match the selected runtime",
1072 ));
1073 }
1074 let mut previous = None;
1075 for file in &receipt.files {
1076 if file.path.is_empty()
1077 || file.path.len() > MAX_RUST_RUNTIME_PATH_BYTES
1078 || file.modified_nanoseconds >= 1_000_000_000
1079 || previous.is_some_and(|path: &str| path >= file.path.as_str())
1080 {
1081 return Err(Error::config(
1082 "managed Rust runtime receipt contains an invalid file inventory",
1083 ));
1084 }
1085 let path = Path::new(&file.path);
1086 if path.is_absolute()
1087 || path
1088 .components()
1089 .any(|component| !matches!(component, std::path::Component::Normal(_)))
1090 {
1091 return Err(Error::config(
1092 "managed Rust runtime receipt contains an unsafe file path",
1093 ));
1094 }
1095 previous = Some(file.path.as_str());
1096 }
1097 Ok(())
1098}
1099
1100fn write_rust_runtime_receipt_atomic(path: &Path, receipt: &RustRuntimeReceipt) -> Result<()> {
1101 let bytes = serde_json::to_vec_pretty(receipt)?;
1102 if bytes.len() as u64 > MAX_RUST_RUNTIME_RECEIPT_BYTES {
1103 return Err(Error::other(
1104 "managed Rust runtime receipt exceeds its size limit",
1105 ));
1106 }
1107 let parent = path
1108 .parent()
1109 .ok_or_else(|| Error::other(format!("path has no parent: {}", path.display())))?;
1110 let serial = NEXT_STAGE.fetch_add(1, Ordering::Relaxed);
1111 let temporary = parent.join(format!(
1112 ".{RUST_RUNTIME_RECEIPT_FILE}.tmp-{}-{serial}",
1113 std::process::id()
1114 ));
1115 let result = (|| {
1116 let mut options = std::fs::OpenOptions::new();
1117 options.create_new(true).write(true);
1118 #[cfg(unix)]
1119 {
1120 use std::os::unix::fs::OpenOptionsExt as _;
1121 options.mode(0o600);
1122 }
1123 let mut file = options
1124 .open(&temporary)
1125 .map_err(|error| Error::io(&temporary, error))?;
1126 use std::io::Write as _;
1127 file.write_all(&bytes)
1128 .map_err(|error| Error::io(&temporary, error))?;
1129 file.sync_all()
1130 .map_err(|error| Error::io(&temporary, error))?;
1131 atomic_replace_runtime_receipt(&temporary, path)
1132 })();
1133 if result.is_err() {
1134 let _ = std::fs::remove_file(&temporary);
1135 }
1136 result
1137}
1138
1139#[cfg(not(windows))]
1140fn atomic_replace_runtime_receipt(source: &Path, destination: &Path) -> Result<()> {
1141 std::fs::rename(source, destination).map_err(|error| Error::io(destination, error))
1142}
1143
1144#[cfg(windows)]
1145fn atomic_replace_runtime_receipt(source: &Path, destination: &Path) -> Result<()> {
1146 use std::os::windows::ffi::OsStrExt as _;
1147 use windows_sys::Win32::Storage::FileSystem::{
1148 MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
1149 };
1150
1151 let source_wide = source
1152 .as_os_str()
1153 .encode_wide()
1154 .chain(Some(0))
1155 .collect::<Vec<_>>();
1156 let destination_wide = destination
1157 .as_os_str()
1158 .encode_wide()
1159 .chain(Some(0))
1160 .collect::<Vec<_>>();
1161 let result = unsafe {
1162 MoveFileExW(
1163 source_wide.as_ptr(),
1164 destination_wide.as_ptr(),
1165 MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
1166 )
1167 };
1168 if result == 0 {
1169 return Err(Error::io(destination, std::io::Error::last_os_error()));
1170 }
1171 Ok(())
1172}
1173
1174fn hash_identity_value(hasher: &mut blake3::Hasher, bytes: &[u8]) {
1175 hasher.update(&(bytes.len() as u64).to_le_bytes());
1176 hasher.update(bytes);
1177}
1178
1179#[derive(Debug, Clone)]
1181pub struct NativeToolLifecycle {
1182 locator: InstallLocator,
1183 family: NativeToolFamily,
1184}
1185
1186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1187pub enum NativeToolFamily {
1188 Cargo,
1189 Go,
1190}
1191
1192impl NativeToolFamily {
1193 pub fn runtime(self) -> &'static str {
1194 match self {
1195 Self::Cargo => "rust",
1196 Self::Go => "go",
1197 }
1198 }
1199
1200 fn providers(self) -> &'static [NativeToolProvider] {
1201 match self {
1202 Self::Cargo => CARGO_PROVIDERS,
1203 Self::Go => GO_PROVIDERS,
1204 }
1205 }
1206}
1207
1208#[allow(clippy::large_enum_variant)]
1210pub enum NativeToolPreparation {
1211 Reused(PathBuf),
1212 Staged(NativeToolStage),
1213}
1214
1215impl NativeToolLifecycle {
1216 #[allow(clippy::too_many_arguments)]
1217 pub fn new(
1218 dirs: &Dirs,
1219 platform: Platform,
1220 tool: &str,
1221 version: &str,
1222 options: &BTreeMap<String, String>,
1223 family: NativeToolFamily,
1224 runtime: InstallDependency,
1225 materials: BTreeMap<String, String>,
1226 ) -> Result<Self> {
1227 validate_runtime_dependency(&runtime)?;
1228 validate_family_tool_id(family, tool)?;
1229 let expected_runtime = family.runtime();
1230 if runtime.id != expected_runtime {
1231 return Err(Error::config(format!(
1232 "native tool `{tool}` requires runtime `{expected_runtime}`, got `{}`",
1233 runtime.id
1234 )));
1235 }
1236 let identity = InstallIdentity::new(
1237 tool,
1238 version,
1239 platform.to_string(),
1240 InstallScope::Isolated,
1241 options,
1242 vec![runtime],
1243 materials,
1244 )?;
1245 Ok(Self {
1246 locator: InstallLocator::new(dirs, identity)?,
1247 family,
1248 })
1249 }
1250
1251 pub fn from_identity(
1252 dirs: &Dirs,
1253 family: NativeToolFamily,
1254 identity: InstallIdentity,
1255 ) -> Result<Self> {
1256 if identity.scope != InstallScope::Isolated {
1257 return Err(Error::config(
1258 "native dynamic tools require isolated install scope",
1259 ));
1260 }
1261 validate_family_tool_id(family, &identity.tool)?;
1262 let runtime = exact_runtime_dependency(&identity)?;
1263 let expected_runtime = family.runtime();
1264 if runtime.id != expected_runtime {
1265 return Err(Error::config(format!(
1266 "native tool `{}` requires runtime `{expected_runtime}`, got `{}`",
1267 identity.tool, runtime.id
1268 )));
1269 }
1270 Ok(Self {
1271 locator: InstallLocator::new(dirs, identity)?,
1272 family,
1273 })
1274 }
1275
1276 pub fn identity(&self) -> &InstallIdentity {
1277 self.locator.identity()
1278 }
1279
1280 pub fn install_root(&self) -> &Path {
1281 self.locator.install_root()
1282 }
1283
1284 pub fn lock_path(&self) -> &Path {
1285 self.locator.lock_path()
1286 }
1287
1288 pub fn metadata_seal_path(&self) -> PathBuf {
1289 metadata_seal_path(&self.locator)
1290 }
1291
1292 pub async fn acquire_lock(&self) -> Result<crate::lock::FileLock> {
1293 super::dynamic::acquire_install_lock(&self.locator, "native tool").await
1294 }
1295
1296 pub async fn prepare(&self, dirs: &Dirs) -> Result<NativeToolPreparation> {
1300 let lock = self.acquire_lock().await?;
1301 if let Some(root) = self.reuse(dirs)? {
1302 return Ok(NativeToolPreparation::Reused(root));
1303 }
1304 self.stage_with_lock(lock)
1305 .map(NativeToolPreparation::Staged)
1306 }
1307
1308 pub fn validate_complete(&self, dirs: &Dirs) -> Result<bool> {
1311 validate_install_candidate(dirs, self.family, self.install_root(), self.identity())
1312 }
1313
1314 pub fn validate_dynamic_install(
1316 &self,
1317 dirs: &Dirs,
1318 install_root: &Path,
1319 identity: &InstallIdentity,
1320 ) -> Result<bool> {
1321 if identity != self.identity() || install_root != self.install_root() {
1322 return Ok(false);
1323 }
1324 validate_install_candidate(dirs, self.family, install_root, identity)
1325 }
1326
1327 pub fn reuse(&self, dirs: &Dirs) -> Result<Option<PathBuf>> {
1329 if self.validate_complete(dirs)? {
1330 Ok(Some(self.install_root().to_path_buf()))
1331 } else if self.install_root().exists() {
1332 match std::fs::symlink_metadata(self.install_root().join(".osdk-complete")) {
1333 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1334 _ => Err(Error::other(format!(
1335 "refusing to reuse incomplete or invalid native tool install at {}",
1336 self.install_root().display()
1337 ))),
1338 }
1339 } else {
1340 Ok(None)
1341 }
1342 }
1343
1344 fn discard_incomplete_install(&self) -> Result<()> {
1347 let _ = remove_metadata_seal(&self.locator)?;
1348 Ok(())
1349 }
1350
1351 fn stage_with_lock(&self, lock: crate::lock::FileLock) -> Result<NativeToolStage> {
1354 let final_root = self.install_root();
1355 let parent = final_root
1356 .parent()
1357 .ok_or_else(|| Error::other("native tool install root has no parent"))?;
1358 create_managed_directory_chain(self.locator.installs_root(), parent)?;
1359
1360 match std::fs::symlink_metadata(final_root) {
1361 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
1362 return Err(Error::other(format!(
1363 "refusing to replace non-directory native tool install root {}",
1364 final_root.display()
1365 )));
1366 }
1367 Ok(_) => match std::fs::symlink_metadata(final_root.join(".osdk-complete")) {
1368 Ok(metadata) if metadata.file_type().is_file() => {
1369 return Err(Error::other(format!(
1370 "refusing to replace complete native tool install at {}",
1371 final_root.display()
1372 )));
1373 }
1374 Ok(_) => {
1375 return Err(Error::other(format!(
1376 "refusing to replace native tool install with an unsafe completion marker at {}",
1377 final_root.display()
1378 )));
1379 }
1380 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1381 std::fs::remove_dir_all(final_root)
1382 .map_err(|error| Error::io(final_root, error))?
1383 }
1384 Err(error) => return Err(Error::io(final_root.join(".osdk-complete"), error)),
1385 },
1386 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1387 Err(error) => return Err(Error::io(final_root, error)),
1388 }
1389 self.discard_incomplete_install()?;
1390
1391 let component = final_root
1392 .file_name()
1393 .ok_or_else(|| Error::other("native tool install root has no filename"))?
1394 .to_string_lossy();
1395 loop {
1396 let serial = NEXT_STAGE.fetch_add(1, Ordering::Relaxed);
1397 let stage_root = parent.join(format!(
1398 ".{component}.stage-{}-{serial}",
1399 std::process::id()
1400 ));
1401 match std::fs::create_dir(&stage_root) {
1402 Ok(()) => {
1403 return Ok(NativeToolStage {
1404 locator: self.locator.clone(),
1405 family: self.family,
1406 stage_root: Some(stage_root),
1407 _lock: lock,
1408 });
1409 }
1410 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
1411 Err(error) => return Err(Error::io(stage_root, error)),
1412 }
1413 }
1414 }
1415
1416 pub async fn uninstall(&self) -> Result<bool> {
1419 let _lock = self.acquire_lock().await?;
1420 remove_exact_install(&self.locator)
1421 }
1422}
1423
1424pub struct NativeToolStage {
1426 locator: InstallLocator,
1427 family: NativeToolFamily,
1428 stage_root: Option<PathBuf>,
1429 _lock: crate::lock::FileLock,
1430}
1431
1432impl std::fmt::Debug for NativeToolStage {
1433 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1434 formatter
1435 .debug_struct("NativeToolStage")
1436 .field("install_id", &self.locator.identity().install_id)
1437 .field("stage_root", &self.stage_root)
1438 .finish_non_exhaustive()
1439 }
1440}
1441
1442impl NativeToolStage {
1443 pub fn path(&self) -> &Path {
1444 self.stage_root
1445 .as_deref()
1446 .expect("published native tool stage has no path")
1447 }
1448
1449 pub fn bin_dir(&self) -> PathBuf {
1450 self.path().join("bin")
1451 }
1452
1453 pub fn reset(&mut self) -> Result<()> {
1456 let path = self.path().to_path_buf();
1457 let metadata = std::fs::symlink_metadata(&path).map_err(|error| Error::io(&path, error))?;
1458 if metadata.file_type().is_symlink() || !metadata.is_dir() {
1459 return Err(Error::other(format!(
1460 "refusing to reset unsafe native tool stage {}",
1461 path.display()
1462 )));
1463 }
1464 create_managed_directory_chain(
1465 self.locator.installs_root(),
1466 path.parent()
1467 .ok_or_else(|| Error::other("native tool stage has no parent"))?,
1468 )?;
1469 std::fs::remove_dir_all(&path).map_err(|error| Error::io(&path, error))?;
1470 std::fs::create_dir(&path).map_err(|error| Error::io(&path, error))
1471 }
1472
1473 pub fn publish(mut self, provider: NativeToolProvider) -> Result<PathBuf> {
1476 if !self.family.providers().contains(&provider) {
1477 return Err(Error::config(
1478 "native tool provider does not match its namespace",
1479 ));
1480 }
1481 let stage_root = self
1482 .stage_root
1483 .as_deref()
1484 .expect("published native tool stage has no path");
1485 super::dynamic::reject_symlinks(stage_root)?;
1486 for reserved in [
1487 NATIVE_TOOL_RECEIPT_FILE,
1488 crate::inventory::INVENTORY_FILE,
1489 ".osdk-complete",
1490 ] {
1491 let path = stage_root.join(reserved);
1492 if path.exists() {
1493 return Err(Error::other(format!(
1494 "native tool provider wrote reserved metadata path {}",
1495 path.display()
1496 )));
1497 }
1498 }
1499
1500 let (manifest_bins, receipt_bins) = inspect_bins(stage_root)?;
1501 let runtime = exact_runtime_dependency(self.locator.identity())?.clone();
1502 write_receipt(
1503 stage_root,
1504 &NativeToolReceipt {
1505 schema: NATIVE_TOOL_RECEIPT_SCHEMA,
1506 provider,
1507 runtime,
1508 bins: receipt_bins,
1509 },
1510 )?;
1511 let mut manifest = DynamicToolManifest::from_identity(self.locator.identity().clone())?;
1512 manifest.bins = manifest_bins;
1513 manifest.write_atomic(stage_root)?;
1514 std::fs::write(stage_root.join(".osdk-complete"), b"")
1515 .map_err(|error| Error::io(stage_root.join(".osdk-complete"), error))?;
1516
1517 let final_root = self.locator.install_root();
1518 write_metadata_seal(&self.locator, stage_root)?;
1519 if let Err(error) = publish_directory_no_replace(stage_root, final_root) {
1520 let _ = remove_metadata_seal(&self.locator);
1521 return Err(error);
1522 }
1523 self.stage_root = None;
1524 Ok(final_root.to_path_buf())
1525 }
1526}
1527
1528impl Drop for NativeToolStage {
1529 fn drop(&mut self) {
1530 if let Some(path) = self.stage_root.take() {
1531 let _ = std::fs::remove_dir_all(path);
1532 }
1533 }
1534}
1535
1536pub fn receipt_path(install_root: &Path) -> PathBuf {
1537 install_root.join(NATIVE_TOOL_RECEIPT_FILE)
1538}
1539
1540pub fn load_receipt(install_root: &Path) -> Result<NativeToolReceipt> {
1541 let path = receipt_path(install_root);
1542 let bytes = crate::inventory::read_stable_regular_file(&path, MAX_NATIVE_TOOL_RECEIPT_BYTES)
1543 .map_err(|error| Error::io(&path, error))?;
1544 let receipt: NativeToolReceipt = serde_json::from_slice(&bytes)?;
1545 validate_receipt(&receipt)?;
1546 Ok(receipt)
1547}
1548
1549pub fn validate_install_candidate(
1552 dirs: &Dirs,
1553 family: NativeToolFamily,
1554 install_root: &Path,
1555 identity: &InstallIdentity,
1556) -> Result<bool> {
1557 if identity.scope != InstallScope::Isolated
1558 || !is_regular_file(&install_root.join(".osdk-complete"))
1559 || !is_regular_file(&DynamicToolManifest::manifest_path(install_root))
1560 || !is_regular_file(&receipt_path(install_root))
1561 {
1562 return Ok(false);
1563 }
1564 let locator = InstallLocator::new(dirs, identity.clone())?;
1565 if !locator.validates_existing_install_root(install_root) {
1566 return Ok(false);
1567 }
1568 super::dynamic::reject_symlinks(install_root)?;
1569 validate_metadata_seal(&locator)?;
1570 let manifest = DynamicToolManifest::load(install_root)?;
1571 if !manifest.matches_identity(identity) {
1572 return Err(Error::other(format!(
1573 "native tool install identity mismatch at {}",
1574 install_root.display()
1575 )));
1576 }
1577 let receipt = load_receipt(install_root)?;
1578 if !family.providers().contains(&receipt.provider) {
1579 return Ok(false);
1580 }
1581 if &receipt.runtime != exact_runtime_dependency(identity)? {
1582 return Err(Error::other(format!(
1583 "native tool runtime receipt does not match install identity at {}",
1584 install_root.display()
1585 )));
1586 }
1587 if !runtime_is_installed(dirs, &receipt.runtime, identity.platform.as_str()) {
1588 return Ok(false);
1589 }
1590 let expected_bins = manifest
1591 .bins
1592 .iter()
1593 .map(|bin| {
1594 validate_relative_bin_path(&bin.path)?;
1595 let file_name = Path::new(&bin.path)
1596 .file_name()
1597 .and_then(|name| name.to_str())
1598 .ok_or_else(|| Error::config("native inventory bin path is not valid UTF-8"))?;
1599 if executable_stem(file_name)? != bin.name {
1600 return Err(Error::config(format!(
1601 "native inventory bin name does not match its path: `{}`",
1602 bin.name
1603 )));
1604 }
1605 Ok(portable_path_key(&bin.path))
1606 })
1607 .collect::<Result<BTreeSet<_>>>()?;
1608 let receipt_bins = receipt
1609 .bins
1610 .iter()
1611 .map(|bin| portable_path_key(&bin.path))
1612 .collect::<BTreeSet<_>>();
1613 if manifest.bins.len() != expected_bins.len()
1614 || expected_bins.len() != receipt_bins.len()
1615 || !expected_bins.iter().all(|path| receipt_bins.contains(path))
1616 {
1617 return Err(Error::other(format!(
1618 "native tool receipt bins do not match inventory at {}",
1619 install_root.display()
1620 )));
1621 }
1622 for bin in &receipt.bins {
1623 let path = checked_bin_path(install_root, &bin.path)?;
1624 let metadata = std::fs::symlink_metadata(&path).map_err(|error| Error::io(&path, error))?;
1625 if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() != bin.size {
1626 return Err(Error::other(format!(
1627 "native tool binary metadata changed at {}",
1628 path.display()
1629 )));
1630 }
1631 let actual = crate::pipeline::verify::hash_file(&path, HashAlgo::Sha256)?;
1632 if actual != bin.sha256 {
1633 return Err(Error::other(format!(
1634 "native tool binary checksum mismatch at {}",
1635 path.display()
1636 )));
1637 }
1638 }
1639 Ok(true)
1640}
1641
1642pub fn list_installed(
1643 dirs: &Dirs,
1644 platform: Platform,
1645 family: NativeToolFamily,
1646 tool: &str,
1647) -> Result<Vec<String>> {
1648 let report = crate::inventory::scan_installs(&dirs.installs, &ScanOptions::default())?;
1649 let mut versions = BTreeSet::new();
1650 for install in report.installs {
1651 let identity = &install.manifest.identity;
1652 if identity.tool != tool
1653 || identity.platform != platform.to_string()
1654 || identity.scope != InstallScope::Isolated
1655 {
1656 continue;
1657 }
1658 if validate_install_candidate(dirs, family, &install.install_root, identity)? {
1659 versions.insert(identity.version.clone());
1660 }
1661 }
1662 Ok(versions.into_iter().collect())
1663}
1664
1665pub fn remove_exact_install(locator: &InstallLocator) -> Result<bool> {
1666 let root = locator.install_root();
1667 match std::fs::symlink_metadata(root) {
1668 Err(error) if error.kind() == std::io::ErrorKind::NotFound => remove_metadata_seal(locator),
1669 Err(error) => Err(Error::io(root, error)),
1670 Ok(metadata)
1671 if metadata.file_type().is_symlink()
1672 || !metadata.is_dir()
1673 || !locator.validates_existing_install_root(root) =>
1674 {
1675 Err(Error::other(format!(
1676 "refusing to remove unsafe native tool install root {}",
1677 root.display()
1678 )))
1679 }
1680 Ok(_) => {
1681 std::fs::remove_dir_all(root).map_err(|error| Error::io(root, error))?;
1682 let _ = remove_metadata_seal(locator)?;
1683 Ok(true)
1684 }
1685 }
1686}
1687
1688pub fn exact_runtime_dependency(identity: &InstallIdentity) -> Result<&InstallDependency> {
1689 let mut runtimes = identity
1690 .dependencies
1691 .iter()
1692 .filter(|dependency| dependency.kind == InstallDependencyKind::Runtime);
1693 let runtime = runtimes.next().ok_or_else(|| {
1694 Error::config("native tool install identity requires one exact runtime dependency")
1695 })?;
1696 if runtimes.next().is_some() {
1697 return Err(Error::config(
1698 "native tool install identity contains multiple runtime dependencies",
1699 ));
1700 }
1701 validate_runtime_dependency(runtime)?;
1702 Ok(runtime)
1703}
1704
1705fn validate_runtime_dependency(runtime: &InstallDependency) -> Result<()> {
1706 if runtime.kind != InstallDependencyKind::Runtime
1707 || runtime.id.contains(':')
1708 || runtime.version.trim().is_empty()
1709 || runtime.version != runtime.version.trim()
1710 || runtime.version.chars().any(char::is_control)
1711 || runtime
1712 .identity
1713 .as_deref()
1714 .is_none_or(|identity| identity.trim().is_empty() || identity != identity.trim())
1715 {
1716 return Err(Error::config(
1717 "native tool runtime dependency must name one exact managed runtime",
1718 ));
1719 }
1720 Ok(())
1721}
1722
1723fn validate_family_tool_id(family: NativeToolFamily, tool: &str) -> Result<()> {
1724 let subject = match family {
1725 NativeToolFamily::Cargo => tool
1726 .strip_prefix("cargo:")
1727 .filter(|_| crate::tool::ToolId::parse(tool).is_ok_and(|id| id.to_string() == tool)),
1728 NativeToolFamily::Go => tool
1729 .strip_prefix("go:")
1730 .filter(|_| crate::tool::ToolId::parse(tool).is_ok_and(|id| id.to_string() == tool)),
1731 };
1732 if subject.is_none() {
1733 return Err(Error::config(format!(
1734 "invalid canonical {:?} native tool id `{tool}`",
1735 family
1736 )));
1737 }
1738 Ok(())
1739}
1740
1741fn runtime_is_installed(dirs: &Dirs, runtime: &InstallDependency, identity_platform: &str) -> bool {
1742 let marker_root = dirs.install_path(&runtime.id, &runtime.version);
1743 if !is_regular_file(&marker_root.join(".osdk-complete")) {
1744 return false;
1745 }
1746 match runtime.identity.as_deref() {
1747 None => false,
1748 Some(expected) => {
1749 runtime_identity_at(dirs, runtime, identity_platform).as_deref() == Some(expected)
1750 }
1751 }
1752}
1753
1754fn runtime_identity_at(
1755 dirs: &Dirs,
1756 runtime: &InstallDependency,
1757 identity_platform: &str,
1758) -> Option<String> {
1759 match runtime.id.as_str() {
1760 "rust" => {
1761 let marker = dirs.install_path("rust", &runtime.version);
1762 if marker.join(".osdk-linked").exists() {
1763 return None;
1764 }
1765 if identity_platform != Platform::current().to_string() {
1766 return None;
1767 }
1768 rust_runtime_identity(dirs, Platform::current(), &runtime.version).ok()
1769 }
1770 "go" => {
1771 if identity_platform != Platform::current().to_string() {
1772 return None;
1773 }
1774 go_runtime_identity(dirs, Platform::current(), &runtime.version).ok()
1775 }
1776 _ => None,
1777 }
1778}
1779
1780fn hash_runtime_tree(root: &Path) -> Result<String> {
1781 let canonical_root = dunce::canonicalize(root).map_err(|error| Error::io(root, error))?;
1782 let mut files = Vec::new();
1783 for entry in walkdir::WalkDir::new(root).follow_links(false) {
1784 let entry = entry.map_err(|error| Error::other(format!("walkdir: {error}")))?;
1785 if entry.file_type().is_symlink() {
1786 return Err(Error::other(format!(
1787 "runtime identity contains a forbidden symlink: {}",
1788 entry.path().display()
1789 )));
1790 }
1791 if entry.file_type().is_file() {
1792 let canonical = dunce::canonicalize(entry.path())
1793 .map_err(|error| Error::io(entry.path(), error))?;
1794 let relative = canonical.strip_prefix(&canonical_root).map_err(|_| {
1795 Error::other(format!(
1796 "runtime identity path escapes root: {}",
1797 entry.path().display()
1798 ))
1799 })?;
1800 files.push((relative.to_path_buf(), canonical));
1801 }
1802 }
1803 files.sort_by(|left, right| left.0.cmp(&right.0));
1804 let mut hasher = blake3::Hasher::new_derive_key("osdk-native-runtime-tree-v1");
1805 for (relative, path) in files {
1806 let relative = relative.to_string_lossy().replace('\\', "/");
1807 hasher.update(&(relative.len() as u64).to_le_bytes());
1808 hasher.update(relative.as_bytes());
1809 let digest = crate::pipeline::verify::hash_file(&path, HashAlgo::Sha256)?;
1810 hasher.update(digest.as_bytes());
1811 }
1812 Ok(format!("b3-tree-v1:{}", hasher.finalize().to_hex()))
1813}
1814
1815fn inspect_bins(root: &Path) -> Result<(Vec<DynamicToolBin>, Vec<NativeToolBinReceipt>)> {
1816 let bin_dir = root.join("bin");
1817 let metadata =
1818 std::fs::symlink_metadata(&bin_dir).map_err(|error| Error::io(&bin_dir, error))?;
1819 if metadata.file_type().is_symlink() || !metadata.is_dir() {
1820 return Err(Error::other(format!(
1821 "native tool provider did not create a regular bin directory at {}",
1822 bin_dir.display()
1823 )));
1824 }
1825 let mut manifest_bins = Vec::new();
1826 let mut receipt_bins = Vec::new();
1827 for entry in std::fs::read_dir(&bin_dir).map_err(|error| Error::io(&bin_dir, error))? {
1828 let entry = entry.map_err(|error| Error::io(&bin_dir, error))?;
1829 let path = entry.path();
1830 let metadata = std::fs::symlink_metadata(&path).map_err(|error| Error::io(&path, error))?;
1831 if metadata.file_type().is_symlink() || !metadata.is_file() {
1832 return Err(Error::other(format!(
1833 "native tool bin entry is not a regular file: {}",
1834 path.display()
1835 )));
1836 }
1837 if !is_native_executable(&path, &metadata) {
1838 continue;
1839 }
1840 let file_name = path
1841 .file_name()
1842 .and_then(|value| value.to_str())
1843 .ok_or_else(|| Error::other("native tool binary name is not valid UTF-8"))?;
1844 let name = executable_stem(file_name)?;
1845 let relative = format!("bin/{file_name}");
1846 manifest_bins.push(DynamicToolBin {
1847 name,
1848 path: relative.clone(),
1849 });
1850 receipt_bins.push(NativeToolBinReceipt {
1851 path: relative,
1852 size: metadata.len(),
1853 sha256: crate::pipeline::verify::hash_file(&path, HashAlgo::Sha256)?,
1854 });
1855 }
1856 manifest_bins.sort_by(|left, right| (&left.name, &left.path).cmp(&(&right.name, &right.path)));
1857 receipt_bins.sort_by(|left, right| left.path.cmp(&right.path));
1858 if manifest_bins.is_empty() {
1859 return Err(Error::other(
1860 "native tool provider did not publish any executable",
1861 ));
1862 }
1863 let mut names = BTreeSet::new();
1864 for bin in &manifest_bins {
1865 if !names.insert(portable_path_key(&bin.name)) {
1866 return Err(Error::other(
1867 "native tool provider published duplicate executable names",
1868 ));
1869 }
1870 }
1871 Ok((manifest_bins, receipt_bins))
1872}
1873
1874fn write_receipt(root: &Path, receipt: &NativeToolReceipt) -> Result<()> {
1875 validate_receipt(receipt)?;
1876 let path = receipt_path(root);
1877 let bytes = serde_json::to_vec_pretty(receipt)?;
1878 std::fs::write(&path, bytes).map_err(|error| Error::io(path, error))
1879}
1880
1881fn metadata_seal_path(locator: &InstallLocator) -> PathBuf {
1882 locator.install_root().with_extension(
1883 NATIVE_TOOL_SEAL_SUFFIX
1884 .strip_prefix('.')
1885 .expect("seal suffix starts with a dot"),
1886 )
1887}
1888
1889fn content_digest(install_root: &Path) -> Result<String> {
1890 let mut hasher = blake3::Hasher::new_derive_key("osdk-native-install-metadata-v1");
1891 let mut files = Vec::new();
1892 for entry in walkdir::WalkDir::new(install_root).follow_links(false) {
1893 let entry = entry.map_err(|error| Error::other(format!("walkdir: {error}")))?;
1894 if entry.file_type().is_symlink() {
1895 return Err(Error::other(format!(
1896 "native tool content contains a forbidden symlink: {}",
1897 entry.path().display()
1898 )));
1899 }
1900 if entry.file_type().is_file() {
1901 let relative = entry
1902 .path()
1903 .strip_prefix(install_root)
1904 .map_err(|_| Error::other("native tool content escaped its root"))?
1905 .to_path_buf();
1906 files.push((relative, entry.into_path()));
1907 }
1908 }
1909 files.sort_by(|left, right| left.0.cmp(&right.0));
1910 for (relative, path) in files {
1911 let relative = relative.to_string_lossy().replace('\\', "/");
1912 hasher.update(&(relative.len() as u64).to_le_bytes());
1913 hasher.update(relative.as_bytes());
1914 let digest = crate::pipeline::verify::hash_file(&path, HashAlgo::Sha256)?;
1915 hasher.update(digest.as_bytes());
1916 }
1917 Ok(hasher.finalize().to_hex().to_string())
1918}
1919
1920fn write_metadata_seal(locator: &InstallLocator, install_root: &Path) -> Result<()> {
1921 let path = metadata_seal_path(locator);
1922 let seal = NativeToolSeal {
1923 schema: 1,
1924 install_id: locator.identity().install_id.clone(),
1925 content_blake3: content_digest(install_root)?,
1926 };
1927 let bytes = serde_json::to_vec_pretty(&seal)?;
1928 let mut options = std::fs::OpenOptions::new();
1929 options.write(true).create_new(true);
1930 #[cfg(unix)]
1931 {
1932 use std::os::unix::fs::OpenOptionsExt;
1933 options.mode(0o600);
1934 }
1935 let mut file = options
1936 .open(&path)
1937 .map_err(|error| Error::io(&path, error))?;
1938 use std::io::Write as _;
1939 file.write_all(&bytes)
1940 .map_err(|error| Error::io(&path, error))?;
1941 file.sync_all().map_err(|error| Error::io(&path, error))
1942}
1943
1944fn validate_metadata_seal(locator: &InstallLocator) -> Result<()> {
1945 let path = metadata_seal_path(locator);
1946 let bytes = crate::inventory::read_stable_regular_file(&path, MAX_NATIVE_TOOL_RECEIPT_BYTES)
1947 .map_err(|error| Error::io(&path, error))?;
1948 let seal: NativeToolSeal = serde_json::from_slice(&bytes)?;
1949 let actual = content_digest(locator.install_root())?;
1950 if seal.schema != 1
1951 || seal.install_id != locator.identity().install_id
1952 || seal.content_blake3 != actual
1953 {
1954 return Err(Error::other(format!(
1955 "native tool metadata seal mismatch at {}",
1956 path.display()
1957 )));
1958 }
1959 Ok(())
1960}
1961
1962fn remove_metadata_seal(locator: &InstallLocator) -> Result<bool> {
1963 let path = metadata_seal_path(locator);
1964 match std::fs::remove_file(&path) {
1965 Ok(()) => Ok(true),
1966 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1967 Err(error) => Err(Error::io(path, error)),
1968 }
1969}
1970
1971fn validate_receipt(receipt: &NativeToolReceipt) -> Result<()> {
1972 if receipt.schema != NATIVE_TOOL_RECEIPT_SCHEMA {
1973 return Err(Error::config(format!(
1974 "unsupported native tool receipt schema `{}`",
1975 receipt.schema
1976 )));
1977 }
1978 validate_runtime_dependency(&receipt.runtime)?;
1979 if receipt.bins.is_empty() {
1980 return Err(Error::config("native tool receipt has no binaries"));
1981 }
1982 let mut previous = None;
1983 let mut portable_paths = BTreeSet::new();
1984 for bin in &receipt.bins {
1985 validate_relative_bin_path(&bin.path)?;
1986 validate_sha256(&bin.sha256)?;
1987 if previous.is_some_and(|path| path >= bin.path.as_str()) {
1988 return Err(Error::config(
1989 "native tool receipt binaries are not canonical",
1990 ));
1991 }
1992 previous = Some(bin.path.as_str());
1993 if !portable_paths.insert(portable_path_key(&bin.path)) {
1994 return Err(Error::config(
1995 "native tool receipt contains a case-insensitive binary path collision",
1996 ));
1997 }
1998 }
1999 Ok(())
2000}
2001
2002fn checked_bin_path(root: &Path, relative: &str) -> Result<PathBuf> {
2003 validate_relative_bin_path(relative)?;
2004 let path = root.join(relative);
2005 let canonical_root = dunce::canonicalize(root).map_err(|error| Error::io(root, error))?;
2006 let canonical = dunce::canonicalize(&path).map_err(|error| Error::io(&path, error))?;
2007 if !canonical.starts_with(&canonical_root) {
2008 return Err(Error::config(format!(
2009 "native tool binary escapes install root: `{relative}`"
2010 )));
2011 }
2012 Ok(path)
2013}
2014
2015fn validate_relative_bin_path(value: &str) -> Result<()> {
2016 let path = Path::new(value);
2017 let mut components = path.components();
2018 if components.next() != Some(std::path::Component::Normal("bin".as_ref()))
2019 || !matches!(components.next(), Some(std::path::Component::Normal(_)))
2020 || components.next().is_some()
2021 || value.contains('\\')
2022 {
2023 return Err(Error::config(format!(
2024 "native tool binary path must be `bin/<name>`: `{value}`"
2025 )));
2026 }
2027 let name = path
2028 .file_name()
2029 .and_then(|name| name.to_str())
2030 .ok_or_else(|| {
2031 Error::config(format!(
2032 "native tool binary path is not valid UTF-8: `{value}`"
2033 ))
2034 })?;
2035 validate_portable_filename(name)?;
2036 Ok(())
2037}
2038
2039fn validate_portable_filename(value: &str) -> Result<()> {
2040 let trimmed = value.trim_end_matches([' ', '.']);
2041 let device = trimmed.split('.').next().unwrap_or(trimmed);
2042 let upper = device.to_ascii_uppercase();
2043 let reserved = matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
2044 || (upper.len() == 4
2045 && matches!(&upper[..3], "COM" | "LPT")
2046 && matches!(upper.as_bytes()[3], b'1'..=b'9'));
2047 if trimmed.is_empty()
2048 || trimmed != value
2049 || reserved
2050 || value.chars().any(|character| {
2051 character.is_control()
2052 || matches!(
2053 character,
2054 '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'
2055 )
2056 })
2057 {
2058 return Err(Error::config(format!(
2059 "native tool binary name is not portable: `{value}`"
2060 )));
2061 }
2062 Ok(())
2063}
2064
2065fn portable_path_key(value: &str) -> String {
2066 value.replace('\\', "/").to_lowercase()
2067}
2068
2069fn create_managed_directory_chain(base: &Path, path: &Path) -> Result<()> {
2070 let relative = path.strip_prefix(base).map_err(|_| {
2071 Error::other(format!(
2072 "native tool install parent is outside the managed root {}: {}",
2073 base.display(),
2074 path.display()
2075 ))
2076 })?;
2077
2078 std::fs::create_dir_all(base).map_err(|error| Error::io(base, error))?;
2083 validate_native_install_directory(base)?;
2084
2085 let mut current = base.to_path_buf();
2086 for component in relative.components() {
2087 let std::path::Component::Normal(component) = component else {
2088 return Err(Error::other(format!(
2089 "native tool install parent contains a non-canonical component: {}",
2090 path.display()
2091 )));
2092 };
2093 current.push(component);
2094 let metadata = match std::fs::symlink_metadata(¤t) {
2095 Ok(metadata) => metadata,
2096 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2097 match std::fs::create_dir(¤t) {
2098 Ok(()) => {}
2099 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
2100 Err(error) => return Err(Error::io(¤t, error)),
2101 }
2102 std::fs::symlink_metadata(¤t).map_err(|error| Error::io(¤t, error))?
2103 }
2104 Err(error) => return Err(Error::io(¤t, error)),
2105 };
2106 validate_native_install_directory_metadata(¤t, &metadata)?;
2107 }
2108 Ok(())
2109}
2110
2111fn validate_native_install_directory(path: &Path) -> Result<()> {
2112 let metadata = std::fs::symlink_metadata(path).map_err(|error| Error::io(path, error))?;
2113 validate_native_install_directory_metadata(path, &metadata)
2114}
2115
2116fn validate_native_install_directory_metadata(
2117 path: &Path,
2118 metadata: &std::fs::Metadata,
2119) -> Result<()> {
2120 if metadata.file_type().is_symlink() || !metadata.is_dir() {
2121 return Err(Error::other(format!(
2122 "native tool install parent is not a regular directory: {}",
2123 path.display()
2124 )));
2125 }
2126 Ok(())
2127}
2128
2129#[cfg(target_os = "linux")]
2130fn publish_directory_no_replace(source: &Path, destination: &Path) -> Result<()> {
2131 use std::os::unix::ffi::OsStrExt;
2132
2133 let source_bytes = source.as_os_str().as_bytes();
2134 let destination_bytes = destination.as_os_str().as_bytes();
2135 let source_c = std::ffi::CString::new(source_bytes)
2136 .map_err(|_| Error::config("native tool staging path contains NUL"))?;
2137 let destination_c = std::ffi::CString::new(destination_bytes)
2138 .map_err(|_| Error::config("native tool install path contains NUL"))?;
2139 let result = unsafe {
2140 libc::syscall(
2141 libc::SYS_renameat2,
2142 libc::AT_FDCWD,
2143 source_c.as_ptr(),
2144 libc::AT_FDCWD,
2145 destination_c.as_ptr(),
2146 libc::RENAME_NOREPLACE,
2147 )
2148 };
2149 if result == 0 {
2150 return Ok(());
2151 }
2152 let error = std::io::Error::last_os_error();
2153 Err(Error::io(destination, error))
2154}
2155
2156#[cfg(target_os = "macos")]
2157fn publish_directory_no_replace(source: &Path, destination: &Path) -> Result<()> {
2158 use std::os::unix::ffi::OsStrExt;
2159
2160 let source_c = std::ffi::CString::new(source.as_os_str().as_bytes())
2161 .map_err(|_| Error::config("native tool staging path contains NUL"))?;
2162 let destination_c = std::ffi::CString::new(destination.as_os_str().as_bytes())
2163 .map_err(|_| Error::config("native tool install path contains NUL"))?;
2164 let result =
2165 unsafe { libc::renamex_np(source_c.as_ptr(), destination_c.as_ptr(), libc::RENAME_EXCL) };
2166 if result == 0 {
2167 Ok(())
2168 } else {
2169 Err(Error::io(destination, std::io::Error::last_os_error()))
2170 }
2171}
2172
2173#[cfg(windows)]
2174fn publish_directory_no_replace(source: &Path, destination: &Path) -> Result<()> {
2175 use std::os::windows::ffi::OsStrExt;
2176 use windows_sys::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_WRITE_THROUGH};
2177
2178 let source: Vec<u16> = source.as_os_str().encode_wide().chain(Some(0)).collect();
2179 let destination_wide: Vec<u16> = destination
2180 .as_os_str()
2181 .encode_wide()
2182 .chain(Some(0))
2183 .collect();
2184 let result = unsafe {
2185 MoveFileExW(
2186 source.as_ptr(),
2187 destination_wide.as_ptr(),
2188 MOVEFILE_WRITE_THROUGH,
2189 )
2190 };
2191 if result != 0 {
2192 Ok(())
2193 } else {
2194 Err(Error::io(destination, std::io::Error::last_os_error()))
2195 }
2196}
2197
2198#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
2199fn publish_directory_no_replace(_source: &Path, destination: &Path) -> Result<()> {
2200 Err(Error::other(format!(
2201 "atomic no-replace publication is unsupported for native tools on this platform: {}",
2202 destination.display()
2203 )))
2204}
2205
2206fn validate_sha256(value: &str) -> Result<()> {
2207 if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
2208 return Err(Error::config("native tool binary has an invalid SHA-256"));
2209 }
2210 if value.bytes().any(|byte| byte.is_ascii_uppercase()) {
2211 return Err(Error::config(
2212 "native tool binary SHA-256 must be lowercase",
2213 ));
2214 }
2215 Ok(())
2216}
2217
2218#[cfg(not(windows))]
2219fn is_native_executable(_path: &Path, metadata: &std::fs::Metadata) -> bool {
2220 use std::os::unix::fs::PermissionsExt;
2221 metadata.permissions().mode() & 0o111 != 0
2222}
2223
2224#[cfg(windows)]
2225fn is_native_executable(path: &Path, _metadata: &std::fs::Metadata) -> bool {
2226 path.extension()
2227 .and_then(|extension| extension.to_str())
2228 .is_some_and(|extension| extension.eq_ignore_ascii_case("exe"))
2229}
2230
2231fn executable_stem(file_name: &str) -> Result<String> {
2232 validate_portable_filename(file_name)?;
2233 #[cfg(windows)]
2234 let file_name =
2235 if file_name.len() > 4 && file_name[file_name.len() - 4..].eq_ignore_ascii_case(".exe") {
2236 &file_name[..file_name.len() - 4]
2237 } else {
2238 return Err(Error::config(
2239 "native tool executable must use the .exe extension",
2240 ));
2241 };
2242 if file_name.is_empty() {
2243 return Err(Error::config("native tool executable name is empty"));
2244 }
2245 Ok(file_name.to_string())
2246}
2247
2248fn is_regular_file(path: &Path) -> bool {
2249 std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_file())
2250}
2251
2252#[cfg(test)]
2253mod tests {
2254 use super::*;
2255
2256 fn dirs(root: &Path) -> Dirs {
2257 Dirs::resolve_from(|key| match key {
2258 "OSDK_DATA_DIR" => Some(root.join("data").display().to_string()),
2259 "OSDK_CACHE_DIR" => Some(root.join("cache").display().to_string()),
2260 "OSDK_CONFIG_DIR" => Some(root.join("config").display().to_string()),
2261 "OSDK_STORE_DIR" => Some(root.join("store").display().to_string()),
2262 "OSDK_INSTALL_DIR" => Some(root.join("installs").display().to_string()),
2263 _ => None,
2264 })
2265 .unwrap()
2266 }
2267
2268 fn lifecycle(root: &Path, runtime_version: &str) -> NativeToolLifecycle {
2269 let dirs = dirs(root);
2270 write_runtime(&dirs, runtime_version);
2271 let runtime_identity =
2272 go_runtime_identity(&dirs, Platform::current(), runtime_version).unwrap();
2273 let identity = InstallIdentity::new(
2274 "go:example.com/acme/fixture",
2275 "1.2.3",
2276 Platform::current().to_string(),
2277 InstallScope::Isolated,
2278 &BTreeMap::new(),
2279 vec![InstallDependency {
2280 kind: InstallDependencyKind::Runtime,
2281 id: "go".into(),
2282 version: runtime_version.into(),
2283 identity: Some(runtime_identity),
2284 }],
2285 BTreeMap::new(),
2286 )
2287 .unwrap();
2288 NativeToolLifecycle {
2289 locator: InstallLocator::new(&dirs, identity).unwrap(),
2290 family: NativeToolFamily::Go,
2291 }
2292 }
2293
2294 fn cargo_lifecycle(root: &Path, runtime_version: &str) -> NativeToolLifecycle {
2295 let dirs = dirs(root);
2296 let runtime_root = dirs.rustup_home().join("toolchains").join(runtime_version);
2297 write_executable(
2298 &runtime_root
2299 .join("bin")
2300 .join(format!("cargo{}", Platform::current().os.exe_suffix())),
2301 b"cargo",
2302 );
2303 write_executable(
2304 &runtime_root
2305 .join("bin")
2306 .join(format!("rustc{}", Platform::current().os.exe_suffix())),
2307 b"rustc",
2308 );
2309 let target_lib = runtime_root
2310 .join("lib/rustlib")
2311 .join(Platform::current().llvm_triple())
2312 .join("lib");
2313 std::fs::create_dir_all(&target_lib).unwrap();
2314 std::fs::write(target_lib.join("libstd-fixture.rlib"), b"std").unwrap();
2315 std::fs::write(target_lib.join("libcore-fixture.rlib"), b"core").unwrap();
2316 std::fs::write(target_lib.join("liballoc-fixture.rlib"), b"alloc").unwrap();
2317 std::fs::write(
2318 runtime_root.join("lib/librustc_driver-fixture.so"),
2319 b"driver",
2320 )
2321 .unwrap();
2322 std::fs::write(
2323 runtime_root.join("lib/rustlib/manifest-rustc-fixture"),
2324 format!(
2325 "file:bin/rustc{}\nfile:lib/librustc_driver-fixture.so",
2326 Platform::current().os.exe_suffix()
2327 ),
2328 )
2329 .unwrap();
2330 std::fs::write(
2331 runtime_root.join("lib/rustlib/manifest-rust-std-fixture"),
2332 b"file:libstd-fixture.rlib",
2333 )
2334 .unwrap();
2335 std::fs::write(
2336 runtime_root.join("lib/rustlib/manifest-cargo-fixture"),
2337 format!("file:bin/cargo{}", Platform::current().os.exe_suffix()),
2338 )
2339 .unwrap();
2340 let marker = dirs.install_path("rust", runtime_version);
2341 std::fs::create_dir_all(&marker).unwrap();
2342 std::fs::write(marker.join(".osdk-complete"), b"").unwrap();
2343 let runtime_identity =
2344 rust_runtime_identity(&dirs, Platform::current(), runtime_version).unwrap();
2345 let identity = InstallIdentity::new(
2346 "cargo:ripgrep",
2347 "14.1.1",
2348 Platform::current().to_string(),
2349 InstallScope::Isolated,
2350 &BTreeMap::new(),
2351 vec![InstallDependency {
2352 kind: InstallDependencyKind::Runtime,
2353 id: "rust".into(),
2354 version: runtime_version.into(),
2355 identity: Some(runtime_identity),
2356 }],
2357 BTreeMap::new(),
2358 )
2359 .unwrap();
2360 NativeToolLifecycle {
2361 locator: InstallLocator::new(&dirs, identity).unwrap(),
2362 family: NativeToolFamily::Cargo,
2363 }
2364 }
2365
2366 fn write_runtime(dirs: &Dirs, version: &str) {
2367 let root = dirs.install_path("go", version);
2368 for directory in ["bin", "pkg/tool", "src/runtime"] {
2369 std::fs::create_dir_all(root.join(directory)).unwrap();
2370 }
2371 write_executable(
2372 &root
2373 .join("bin")
2374 .join(format!("go{}", Platform::current().os.exe_suffix())),
2375 b"go",
2376 );
2377 write_executable(
2378 &root
2379 .join("bin")
2380 .join(format!("gofmt{}", Platform::current().os.exe_suffix())),
2381 b"gofmt",
2382 );
2383 write_executable(
2384 &root
2385 .join("pkg/tool")
2386 .join(format!("compile{}", Platform::current().os.exe_suffix())),
2387 b"compile",
2388 );
2389 std::fs::write(root.join("src/runtime/runtime.go"), b"package runtime").unwrap();
2390 std::fs::write(root.join("VERSION"), format!("go{version}\n")).unwrap();
2391 std::fs::write(root.join("go.env"), b"GOTOOLCHAIN=local\n").unwrap();
2392 std::fs::write(root.join(".osdk-complete"), b"").unwrap();
2393 }
2394
2395 fn write_runtime_with_identity(dirs: &Dirs, version: &str, identity: &str) {
2396 let root = dirs.install_path("go", version);
2397 write_runtime(dirs, version);
2398 std::fs::write(root.join("src/runtime/identity"), identity).unwrap();
2399 }
2400
2401 fn write_executable(path: &Path, bytes: &[u8]) {
2402 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2403 std::fs::write(path, bytes).unwrap();
2404 #[cfg(unix)]
2405 {
2406 use std::os::unix::fs::PermissionsExt;
2407 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2408 }
2409 }
2410
2411 fn new_stage(lifecycle: &NativeToolLifecycle) -> NativeToolStage {
2412 let lock = crate::lock::FileLock::acquire(lifecycle.locator.lock_path()).unwrap();
2413 lifecycle.stage_with_lock(lock).unwrap()
2414 }
2415
2416 #[test]
2417 fn publishes_and_validates_identity_runtime_and_bin_hashes() {
2418 let temporary = tempfile::tempdir().unwrap();
2419 let dirs = dirs(temporary.path());
2420 write_runtime(&dirs, "1.23.4");
2421 let lifecycle = lifecycle(temporary.path(), "1.23.4");
2422 let stage = new_stage(&lifecycle);
2423 let executable = stage.bin_dir().join(if cfg!(windows) {
2424 "fixture.exe"
2425 } else {
2426 "fixture"
2427 });
2428 write_executable(&executable, b"native fixture");
2429 let root = stage.publish(NativeToolProvider::GoInstall).unwrap();
2430
2431 assert!(lifecycle.validate_complete(&dirs).unwrap());
2432 let receipt = load_receipt(&root).unwrap();
2433 assert_eq!(receipt.runtime.version, "1.23.4");
2434 assert_eq!(receipt.bins.len(), 1);
2435 assert_eq!(receipt.bins[0].size, 14);
2436 assert_eq!(receipt.bins[0].sha256.len(), 64);
2437 assert_eq!(
2438 list_installed(
2439 &dirs,
2440 Platform::current(),
2441 NativeToolFamily::Go,
2442 "go:example.com/acme/fixture",
2443 )
2444 .unwrap(),
2445 vec!["1.2.3"]
2446 );
2447 }
2448
2449 #[test]
2450 fn runtime_version_changes_the_install_root() {
2451 let temporary = tempfile::tempdir().unwrap();
2452 let first = lifecycle(temporary.path(), "1.22.0");
2453 let second = lifecycle(temporary.path(), "1.23.0");
2454 assert_ne!(first.install_root(), second.install_root());
2455 assert_ne!(first.identity().install_id, second.identity().install_id);
2456 }
2457
2458 #[test]
2459 fn rust_runtime_identity_covers_complete_build_critical_payloads() {
2460 let temporary = tempfile::tempdir().unwrap();
2461 let lifecycle = cargo_lifecycle(temporary.path(), "1.91.1");
2462 let dirs = dirs(temporary.path());
2463 let runtime_root = dirs.rustup_home().join("toolchains/1.91.1");
2464 let first = rust_runtime_identity(&dirs, Platform::current(), "1.91.1").unwrap();
2465 assert!(first.starts_with("b3-rust-v2:"));
2466 let receipt_path = dirs
2467 .install_path("rust", "1.91.1")
2468 .join(RUST_RUNTIME_RECEIPT_FILE);
2469 let first_receipt = std::fs::read(&receipt_path).unwrap();
2470
2471 std::fs::create_dir_all(runtime_root.join("share/doc")).unwrap();
2472 std::fs::write(runtime_root.join("share/doc/unrelated.html"), b"one").unwrap();
2473 let unrelated = rust_runtime_identity(&dirs, Platform::current(), "1.91.1").unwrap();
2474 assert_eq!(first, unrelated);
2475 assert_eq!(first_receipt, std::fs::read(&receipt_path).unwrap());
2476 assert_eq!(
2477 lifecycle.identity().dependencies[0].identity.as_deref(),
2478 Some(first.as_str())
2479 );
2480
2481 write_executable(
2482 &runtime_root
2483 .join("bin")
2484 .join(format!("rustc{}", Platform::current().os.exe_suffix())),
2485 b"changed rustc",
2486 );
2487 let changed = rust_runtime_identity(&dirs, Platform::current(), "1.91.1").unwrap();
2488 assert_ne!(first, changed);
2489
2490 let lifecycle = cargo_lifecycle(temporary.path(), "1.91.2");
2491 let runtime_root = dirs.rustup_home().join("toolchains/1.91.2");
2492 let first = lifecycle.identity().dependencies[0]
2493 .identity
2494 .clone()
2495 .unwrap();
2496 std::fs::write(
2497 runtime_root.join("lib/librustc_driver-fixture.so"),
2498 b"changed driver",
2499 )
2500 .unwrap();
2501 assert_ne!(
2502 first,
2503 rust_runtime_identity(&dirs, Platform::current(), "1.91.2").unwrap()
2504 );
2505
2506 let lifecycle = cargo_lifecycle(temporary.path(), "1.91.3");
2507 let runtime_root = dirs.rustup_home().join("toolchains/1.91.3");
2508 let first = lifecycle.identity().dependencies[0]
2509 .identity
2510 .clone()
2511 .unwrap();
2512 let target_lib = runtime_root
2513 .join("lib/rustlib")
2514 .join(Platform::current().llvm_triple())
2515 .join("lib/libstd-fixture.rlib");
2516 std::fs::write(target_lib, b"changed std").unwrap();
2517 assert_ne!(
2518 first,
2519 rust_runtime_identity(&dirs, Platform::current(), "1.91.3").unwrap()
2520 );
2521
2522 for (version, file, changed) in [
2523 ("1.91.4", "libcore-fixture.rlib", b"CORE".as_slice()),
2524 ("1.91.5", "liballoc-fixture.rlib", b"ALLOC".as_slice()),
2525 ] {
2526 let lifecycle = cargo_lifecycle(temporary.path(), version);
2527 let first = lifecycle.identity().dependencies[0]
2528 .identity
2529 .clone()
2530 .unwrap();
2531 let path = dirs
2532 .rustup_home()
2533 .join("toolchains")
2534 .join(version)
2535 .join("lib/rustlib")
2536 .join(Platform::current().llvm_triple())
2537 .join("lib")
2538 .join(file);
2539 std::thread::sleep(std::time::Duration::from_millis(20));
2540 std::fs::write(path, changed).unwrap();
2541 assert_ne!(
2542 first,
2543 rust_runtime_identity(&dirs, Platform::current(), version).unwrap(),
2544 "{file} mutation must change the runtime identity"
2545 );
2546 }
2547 }
2548
2549 #[test]
2550 fn go_runtime_identity_caches_inventory_and_rehashes_build_inputs() {
2551 let temporary = tempfile::tempdir().unwrap();
2552 let dirs = dirs(temporary.path());
2553 write_runtime(&dirs, "1.24.0");
2554 let root = dirs.install_path("go", "1.24.0");
2555
2556 let first = go_runtime_identity(&dirs, Platform::current(), "1.24.0").unwrap();
2557 assert!(first.starts_with("b3-go-v1:"));
2558 let receipt = root.join(GO_RUNTIME_RECEIPT_FILE);
2559 let first_receipt = std::fs::read(&receipt).unwrap();
2560 let second = go_runtime_identity(&dirs, Platform::current(), "1.24.0").unwrap();
2561 assert_eq!(first, second);
2562 assert_eq!(first_receipt, std::fs::read(&receipt).unwrap());
2563
2564 std::thread::sleep(std::time::Duration::from_millis(20));
2565 std::fs::write(
2566 root.join("src/runtime/runtime.go"),
2567 b"package runtime // changed",
2568 )
2569 .unwrap();
2570 let changed = go_runtime_identity(&dirs, Platform::current(), "1.24.0").unwrap();
2571 assert_ne!(first, changed);
2572 }
2573
2574 #[test]
2575 fn go_runtime_identity_rejects_corrupt_receipt() {
2576 let temporary = tempfile::tempdir().unwrap();
2577 let dirs = dirs(temporary.path());
2578 write_runtime(&dirs, "1.24.0");
2579 go_runtime_identity(&dirs, Platform::current(), "1.24.0").unwrap();
2580 let receipt = dirs
2581 .install_path("go", "1.24.0")
2582 .join(GO_RUNTIME_RECEIPT_FILE);
2583 std::fs::write(&receipt, b"{}").unwrap();
2584 assert!(go_runtime_identity(&dirs, Platform::current(), "1.24.0").is_err());
2585 }
2586
2587 #[cfg(unix)]
2588 #[test]
2589 fn go_runtime_identity_accepts_cas_links_and_rejects_external_links() {
2590 use std::os::unix::fs::symlink;
2591
2592 let temporary = tempfile::tempdir().unwrap();
2593 let dirs = dirs(temporary.path());
2594 write_runtime(&dirs, "1.24.0");
2595 let root = dirs.install_path("go", "1.24.0");
2596 let source = root.join("src/runtime/runtime.go");
2597 let cas = dirs.store.join("aa/bb/payload");
2598 std::fs::create_dir_all(cas.parent().unwrap()).unwrap();
2599 std::fs::write(&cas, b"package runtime").unwrap();
2600 std::fs::remove_file(&source).unwrap();
2601 symlink(&cas, &source).unwrap();
2602 let first = go_runtime_identity(&dirs, Platform::current(), "1.24.0").unwrap();
2603
2604 let second_cas = dirs.store.join("cc/dd/payload");
2605 std::fs::create_dir_all(second_cas.parent().unwrap()).unwrap();
2606 std::fs::write(&second_cas, b"package runtime").unwrap();
2607 std::fs::remove_file(&source).unwrap();
2608 symlink(&second_cas, &source).unwrap();
2609 let retargeted = go_runtime_identity(&dirs, Platform::current(), "1.24.0").unwrap();
2610 assert_ne!(first, retargeted);
2611
2612 std::fs::remove_file(&source).unwrap();
2613 let outside = temporary.path().join("outside.go");
2614 std::fs::write(&outside, b"package runtime").unwrap();
2615 symlink(&outside, &source).unwrap();
2616 assert!(go_runtime_identity(&dirs, Platform::current(), "1.24.0").is_err());
2617 }
2618
2619 #[test]
2620 fn rust_runtime_identity_rejects_corrupt_or_symlinked_receipts() {
2621 let temporary = tempfile::tempdir().unwrap();
2622 cargo_lifecycle(temporary.path(), "1.91.6");
2623 let dirs = dirs(temporary.path());
2624 let receipt = dirs
2625 .install_path("rust", "1.91.6")
2626 .join(RUST_RUNTIME_RECEIPT_FILE);
2627 std::fs::write(&receipt, b"{}").unwrap();
2628 assert!(rust_runtime_identity(&dirs, Platform::current(), "1.91.6").is_err());
2629
2630 #[cfg(unix)]
2631 {
2632 use std::os::unix::fs::symlink;
2633
2634 std::fs::remove_file(&receipt).unwrap();
2635 let outside = temporary.path().join("outside-receipt");
2636 std::fs::write(&outside, b"{}").unwrap();
2637 symlink(&outside, &receipt).unwrap();
2638 let error = rust_runtime_identity(&dirs, Platform::current(), "1.91.6").unwrap_err();
2639 assert!(error.to_string().contains("non-symlink"), "{error}");
2640 }
2641 }
2642
2643 #[cfg(unix)]
2644 #[test]
2645 fn rust_runtime_identity_rejects_target_lib_symlinks() {
2646 use std::os::unix::fs::symlink;
2647
2648 let temporary = tempfile::tempdir().unwrap();
2649 cargo_lifecycle(temporary.path(), "1.91.7");
2650 let dirs = dirs(temporary.path());
2651 let target_lib = dirs
2652 .rustup_home()
2653 .join("toolchains/1.91.7/lib/rustlib")
2654 .join(Platform::current().llvm_triple())
2655 .join("lib");
2656 let outside = temporary.path().join("outside-payload");
2657 std::fs::write(&outside, b"payload").unwrap();
2658 symlink(&outside, target_lib.join("libinjected.rlib")).unwrap();
2659 assert!(rust_runtime_identity(&dirs, Platform::current(), "1.91.7").is_err());
2660 }
2661
2662 #[test]
2663 fn failed_or_abandoned_stage_is_removed_without_publishing() {
2664 let temporary = tempfile::tempdir().unwrap();
2665 let lifecycle = lifecycle(temporary.path(), "1.23.4");
2666 let stage = new_stage(&lifecycle);
2667 let stage_root = stage.path().to_path_buf();
2668 drop(stage);
2669 assert!(!stage_root.exists());
2670 assert!(!lifecycle.install_root().exists());
2671
2672 let stage = new_stage(&lifecycle);
2673 let error = stage.publish(NativeToolProvider::GoInstall).unwrap_err();
2674 assert!(error.to_string().contains("bin"), "{error}");
2675 assert!(!lifecycle.install_root().exists());
2676 }
2677
2678 #[test]
2679 fn reset_clears_first_provider_output_without_releasing_the_stage() {
2680 let temporary = tempfile::tempdir().unwrap();
2681 let lifecycle = cargo_lifecycle(temporary.path(), "1.91.1");
2682 let mut stage = new_stage(&lifecycle);
2683 let first = stage.path().join("partial");
2684 std::fs::write(&first, b"binstall partial").unwrap();
2685 stage.reset().unwrap();
2686 assert!(stage.path().is_dir());
2687 assert!(!first.exists());
2688 write_executable(
2689 &stage.bin_dir().join(if cfg!(windows) {
2690 "fixture.exe"
2691 } else {
2692 "fixture"
2693 }),
2694 b"cargo fallback",
2695 );
2696 assert!(stage
2697 .publish(NativeToolProvider::CargoInstall)
2698 .unwrap()
2699 .is_dir());
2700 }
2701
2702 #[test]
2703 fn provider_set_accepts_only_the_tool_namespace() {
2704 let temporary = tempfile::tempdir().unwrap();
2705 let cargo = cargo_lifecycle(temporary.path(), "1.91.1");
2706 let stage = new_stage(&cargo);
2707 write_executable(
2708 &stage.bin_dir().join(if cfg!(windows) {
2709 "fixture.exe"
2710 } else {
2711 "fixture"
2712 }),
2713 b"fixture",
2714 );
2715 assert!(stage
2716 .publish(NativeToolProvider::CargoBinstall)
2717 .unwrap()
2718 .is_dir());
2719
2720 let lifecycle = lifecycle(temporary.path(), "1.23.4");
2721 let stage = new_stage(&lifecycle);
2722 write_executable(
2723 &stage.bin_dir().join(if cfg!(windows) {
2724 "fixture.exe"
2725 } else {
2726 "fixture"
2727 }),
2728 b"fixture",
2729 );
2730 let error = stage
2731 .publish(NativeToolProvider::CargoBinstall)
2732 .unwrap_err();
2733 assert!(error.to_string().contains("does not match"));
2734 }
2735
2736 #[test]
2737 fn tampered_receipt_fails_closed() {
2738 let temporary = tempfile::tempdir().unwrap();
2739 let dirs = dirs(temporary.path());
2740 write_runtime(&dirs, "1.23.4");
2741 let lifecycle = lifecycle(temporary.path(), "1.23.4");
2742 let stage = new_stage(&lifecycle);
2743 write_executable(
2744 &stage.bin_dir().join(if cfg!(windows) {
2745 "fixture.exe"
2746 } else {
2747 "fixture"
2748 }),
2749 b"fixture",
2750 );
2751 let root = stage.publish(NativeToolProvider::GoInstall).unwrap();
2752 let mut receipt = load_receipt(&root).unwrap();
2753 receipt.bins[0].sha256 = "0".repeat(64);
2754 std::fs::write(receipt_path(&root), serde_json::to_vec(&receipt).unwrap()).unwrap();
2755 assert!(lifecycle.validate_complete(&dirs).is_err());
2756 }
2757
2758 #[test]
2759 fn tampered_seal_fails_closed() {
2760 let temporary = tempfile::tempdir().unwrap();
2761 let dirs = dirs(temporary.path());
2762 let lifecycle = lifecycle(temporary.path(), "1.23.4");
2763 let stage = new_stage(&lifecycle);
2764 write_executable(
2765 &stage.bin_dir().join(if cfg!(windows) {
2766 "fixture.exe"
2767 } else {
2768 "fixture"
2769 }),
2770 b"fixture",
2771 );
2772 stage.publish(NativeToolProvider::GoInstall).unwrap();
2773 std::fs::write(lifecycle.metadata_seal_path(), b"{}").unwrap();
2774 assert!(lifecycle.validate_complete(&dirs).is_err());
2775 }
2776
2777 #[test]
2778 fn jointly_rewritten_root_metadata_is_rejected_by_adjacent_seal() {
2779 let temporary = tempfile::tempdir().unwrap();
2780 let dirs = dirs(temporary.path());
2781 let lifecycle = lifecycle(temporary.path(), "1.23.4");
2782 let stage = new_stage(&lifecycle);
2783 write_executable(
2784 &stage.bin_dir().join(if cfg!(windows) {
2785 "fixture.exe"
2786 } else {
2787 "fixture"
2788 }),
2789 b"fixture",
2790 );
2791 let root = stage.publish(NativeToolProvider::GoInstall).unwrap();
2792 let installed_bin = root.join("bin").join(if cfg!(windows) {
2793 "fixture.exe"
2794 } else {
2795 "fixture"
2796 });
2797 std::fs::write(&installed_bin, b"replacement").unwrap();
2798 let mut receipt = load_receipt(&root).unwrap();
2799 receipt.bins[0].size = 11;
2800 receipt.bins[0].sha256 =
2801 crate::pipeline::verify::hash_file(&installed_bin, HashAlgo::Sha256).unwrap();
2802 std::fs::write(
2803 receipt_path(&root),
2804 serde_json::to_vec_pretty(&receipt).unwrap(),
2805 )
2806 .unwrap();
2807 assert!(lifecycle.validate_complete(&dirs).is_err());
2810 assert!(lifecycle.metadata_seal_path().is_file());
2811 }
2812
2813 #[test]
2814 fn unlisted_payload_file_is_bound_by_the_adjacent_seal() {
2815 let temporary = tempfile::tempdir().unwrap();
2816 let dirs = dirs(temporary.path());
2817 let lifecycle = lifecycle(temporary.path(), "1.23.4");
2818 let stage = new_stage(&lifecycle);
2819 write_executable(
2820 &stage.bin_dir().join(if cfg!(windows) {
2821 "fixture.exe"
2822 } else {
2823 "fixture"
2824 }),
2825 b"fixture",
2826 );
2827 let root = stage.publish(NativeToolProvider::GoInstall).unwrap();
2828 std::fs::write(root.join("injected"), b"payload").unwrap();
2829 assert!(lifecycle.validate_complete(&dirs).is_err());
2830 }
2831
2832 #[test]
2833 fn native_runtime_identity_is_mandatory() {
2834 let temporary = tempfile::tempdir().unwrap();
2835 let error = NativeToolLifecycle::new(
2836 &dirs(temporary.path()),
2837 Platform::current(),
2838 "go:example.com/acme/fixture",
2839 "1.2.3",
2840 &BTreeMap::new(),
2841 NativeToolFamily::Go,
2842 InstallDependency {
2843 kind: InstallDependencyKind::Runtime,
2844 id: "go".into(),
2845 version: "1.23.4".into(),
2846 identity: None,
2847 },
2848 BTreeMap::new(),
2849 )
2850 .unwrap_err();
2851 assert!(error.to_string().contains("exact managed runtime"));
2852 }
2853
2854 #[test]
2855 fn family_validation_rejects_malformed_native_ids() {
2856 let temporary = tempfile::tempdir().unwrap();
2857 let dirs = dirs(temporary.path());
2858 let runtime_root = dirs.install_path("go", "1.23.4");
2859 std::fs::create_dir_all(&runtime_root).unwrap();
2860 std::fs::write(runtime_root.join(".osdk-complete"), b"").unwrap();
2861 write_runtime(&dirs, "1.23.4");
2862 let runtime_identity = go_runtime_identity(&dirs, Platform::current(), "1.23.4").unwrap();
2863 for tool in [
2864 "go:exa$mple.com/tool",
2865 "go:-example.com/tool",
2866 "go:example!.com/tool",
2867 ] {
2868 let error = NativeToolLifecycle::new(
2869 &dirs,
2870 Platform::current(),
2871 tool,
2872 "1.2.3",
2873 &BTreeMap::new(),
2874 NativeToolFamily::Go,
2875 InstallDependency {
2876 kind: InstallDependencyKind::Runtime,
2877 id: "go".into(),
2878 version: "1.23.4".into(),
2879 identity: Some(runtime_identity.clone()),
2880 },
2881 BTreeMap::new(),
2882 )
2883 .unwrap_err();
2884 assert!(error.to_string().contains("invalid canonical"), "{error}");
2885 }
2886 }
2887
2888 #[test]
2889 fn tampered_bin_and_missing_runtime_fail_reuse() {
2890 let temporary = tempfile::tempdir().unwrap();
2891 let dirs = dirs(temporary.path());
2892 write_runtime(&dirs, "1.23.4");
2893 let lifecycle = lifecycle(temporary.path(), "1.23.4");
2894 let stage = new_stage(&lifecycle);
2895 let executable = stage.bin_dir().join(if cfg!(windows) {
2896 "fixture.exe"
2897 } else {
2898 "fixture"
2899 });
2900 write_executable(&executable, b"original");
2901 let root = stage.publish(NativeToolProvider::GoInstall).unwrap();
2902 std::fs::write(
2903 executable.with_file_name(executable.file_name().unwrap()),
2904 b"bad",
2905 )
2906 .ok();
2907 let installed_bin = root.join("bin").join(executable.file_name().unwrap());
2908 std::fs::write(&installed_bin, b"tampered").unwrap();
2909 assert!(lifecycle.validate_complete(&dirs).is_err());
2910
2911 std::fs::write(&installed_bin, b"original").unwrap();
2912 std::fs::remove_file(dirs.install_path("go", "1.23.4").join(".osdk-complete")).unwrap();
2913 assert!(!lifecycle.validate_complete(&dirs).unwrap());
2914 }
2915
2916 #[test]
2917 fn bound_runtime_identity_must_still_match() {
2918 let temporary = tempfile::tempdir().unwrap();
2919 let dirs = dirs(temporary.path());
2920 write_runtime_with_identity(&dirs, "1.23.4", "runtime-a");
2921 let expected_identity = go_runtime_identity(&dirs, Platform::current(), "1.23.4").unwrap();
2922 let identity = InstallIdentity::new(
2923 "go:example.com/acme/fixture",
2924 "1.2.3",
2925 Platform::current().to_string(),
2926 InstallScope::Isolated,
2927 &BTreeMap::new(),
2928 vec![InstallDependency {
2929 kind: InstallDependencyKind::Runtime,
2930 id: "go".into(),
2931 version: "1.23.4".into(),
2932 identity: Some(expected_identity),
2933 }],
2934 BTreeMap::new(),
2935 )
2936 .unwrap();
2937 let lifecycle = NativeToolLifecycle {
2938 locator: InstallLocator::new(&dirs, identity).unwrap(),
2939 family: NativeToolFamily::Go,
2940 };
2941 let stage = new_stage(&lifecycle);
2942 write_executable(
2943 &stage.bin_dir().join(if cfg!(windows) {
2944 "fixture.exe"
2945 } else {
2946 "fixture"
2947 }),
2948 b"fixture",
2949 );
2950 stage.publish(NativeToolProvider::GoInstall).unwrap();
2951 assert!(lifecycle.validate_complete(&dirs).unwrap());
2952
2953 std::fs::write(
2954 dirs.install_path("go", "1.23.4")
2955 .join("src/runtime/identity"),
2956 "runtime-b",
2957 )
2958 .unwrap();
2959 assert!(!lifecycle.validate_complete(&dirs).unwrap());
2960 }
2961
2962 #[tokio::test]
2963 async fn prepare_serializes_same_identity_and_reuses_without_a_second_stage() {
2964 let temporary = tempfile::tempdir().unwrap();
2965 let dirs = dirs(temporary.path());
2966 write_runtime(&dirs, "1.23.4");
2967 let lifecycle = lifecycle(temporary.path(), "1.23.4");
2968 let NativeToolPreparation::Staged(first) = lifecycle.prepare(&dirs).await.unwrap() else {
2969 panic!("first prepare must stage");
2970 };
2971 let clone = lifecycle.clone();
2972 let dirs_clone = dirs.clone();
2973 let waiter = tokio::spawn(async move { clone.prepare(&dirs_clone).await });
2974 tokio::task::yield_now().await;
2975 assert!(!waiter.is_finished());
2976 write_executable(
2977 &first.bin_dir().join(if cfg!(windows) {
2978 "fixture.exe"
2979 } else {
2980 "fixture"
2981 }),
2982 b"fixture",
2983 );
2984 let published = first.publish(NativeToolProvider::GoInstall).unwrap();
2985 let NativeToolPreparation::Reused(reused) = waiter.await.unwrap().unwrap() else {
2986 panic!("second prepare must reuse");
2987 };
2988 assert_eq!(published, reused);
2989 }
2990
2991 #[tokio::test]
2992 async fn uninstall_removes_only_the_exact_identity() {
2993 let temporary = tempfile::tempdir().unwrap();
2994 let dirs = dirs(temporary.path());
2995 write_runtime(&dirs, "1.22.0");
2996 write_runtime(&dirs, "1.23.0");
2997 let first = lifecycle(temporary.path(), "1.22.0");
2998 let second = lifecycle(temporary.path(), "1.23.0");
2999 for lifecycle in [&first, &second] {
3000 let stage = new_stage(lifecycle);
3001 write_executable(
3002 &stage.bin_dir().join(if cfg!(windows) {
3003 "fixture.exe"
3004 } else {
3005 "fixture"
3006 }),
3007 lifecycle.identity().install_id.as_bytes(),
3008 );
3009 stage.publish(NativeToolProvider::GoInstall).unwrap();
3010 }
3011 assert!(first.uninstall().await.unwrap());
3012 assert!(!first.install_root().exists());
3013 assert!(second.install_root().exists());
3014 }
3015
3016 #[test]
3017 fn receipt_rejects_case_collisions_and_reserved_windows_names() {
3018 let runtime = InstallDependency {
3019 kind: InstallDependencyKind::Runtime,
3020 id: "go".into(),
3021 version: "1.23.4".into(),
3022 identity: Some("b3-go-v1:fixture".into()),
3023 };
3024 let receipt = NativeToolReceipt {
3025 schema: NATIVE_TOOL_RECEIPT_SCHEMA,
3026 provider: NativeToolProvider::GoInstall,
3027 runtime: runtime.clone(),
3028 bins: vec![
3029 NativeToolBinReceipt {
3030 path: "bin/Tool".into(),
3031 size: 3,
3032 sha256: "0".repeat(64),
3033 },
3034 NativeToolBinReceipt {
3035 path: "bin/tool".into(),
3036 size: 3,
3037 sha256: "1".repeat(64),
3038 },
3039 ],
3040 };
3041 let error = validate_receipt(&receipt).unwrap_err();
3042 assert!(error.to_string().contains("case-insensitive"));
3043
3044 let receipt = NativeToolReceipt {
3045 bins: vec![NativeToolBinReceipt {
3046 path: "bin/CON".into(),
3047 size: 3,
3048 sha256: "0".repeat(64),
3049 }],
3050 runtime,
3051 ..receipt
3052 };
3053 let error = validate_receipt(&receipt).unwrap_err();
3054 assert!(error.to_string().contains("not portable"));
3055 }
3056
3057 #[test]
3058 fn publishing_never_replaces_an_existing_final_root() {
3059 let temporary = tempfile::tempdir().unwrap();
3060 let lifecycle = lifecycle(temporary.path(), "1.23.4");
3061 let stage = new_stage(&lifecycle);
3062 write_executable(
3063 &stage.bin_dir().join(if cfg!(windows) {
3064 "fixture.exe"
3065 } else {
3066 "fixture"
3067 }),
3068 b"new",
3069 );
3070 std::fs::create_dir_all(lifecycle.install_root()).unwrap();
3071 std::fs::write(lifecycle.install_root().join("sentinel"), b"old").unwrap();
3072
3073 let error = stage.publish(NativeToolProvider::GoInstall).unwrap_err();
3074 assert!(!error.to_string().is_empty());
3075 assert_eq!(
3076 std::fs::read(lifecycle.install_root().join("sentinel")).unwrap(),
3077 b"old"
3078 );
3079 }
3080
3081 #[cfg(unix)]
3082 #[test]
3083 fn staging_rejects_symlinked_install_ancestor() {
3084 use std::os::unix::fs::symlink;
3085
3086 let temporary = tempfile::tempdir().unwrap();
3087 let lifecycle = lifecycle(temporary.path(), "1.23.4");
3088 let tool_root = lifecycle
3089 .install_root()
3090 .parent()
3091 .and_then(Path::parent)
3092 .unwrap()
3093 .to_path_buf();
3094 let outside = temporary.path().join("outside");
3095 std::fs::create_dir_all(&outside).unwrap();
3096 std::fs::create_dir_all(tool_root.parent().unwrap()).unwrap();
3097 symlink(&outside, &tool_root).unwrap();
3098
3099 let lock = crate::lock::FileLock::acquire(lifecycle.locator.lock_path()).unwrap();
3100 let error = lifecycle.stage_with_lock(lock).unwrap_err();
3101 assert!(error.to_string().contains("regular directory"), "{error}");
3102 }
3103
3104 #[cfg(unix)]
3105 #[test]
3106 fn staging_accepts_a_real_install_root_below_a_platform_alias() {
3107 use std::os::unix::fs::symlink;
3108
3109 let temporary = tempfile::tempdir().unwrap();
3110 let real = temporary.path().join("real");
3111 std::fs::create_dir(&real).unwrap();
3112 let alias = temporary.path().join("alias");
3113 symlink(&real, &alias).unwrap();
3114
3115 let lifecycle = lifecycle(&alias, "1.23.4");
3116 let stage = new_stage(&lifecycle);
3117 assert!(stage.path().starts_with(alias.join("installs")));
3118 }
3119}