Skip to main content

osdk_core/backend/
native_tool.rs

1//! Shared lifecycle for dynamic tools installed by a managed native runtime.
2//!
3//! Cargo and Go-module tools both delegate compilation or package resolution to
4//! an exact managed runtime. This module owns the common durable boundary: an
5//! identity-qualified lock and install root, a sibling staging directory, a
6//! receipt binding the provider/runtime/binary bytes, an adjacent metadata seal,
7//! and fail-closed reuse, listing, and removal helpers. The seal detects
8//! accidental or isolated install-root mutation; it is not a security boundary
9//! against a process that can also rewrite the user's osdk state directory.
10//! Backend-specific command planning stays in the individual backend modules.
11
12use 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    /// Slash-separated path relative to the install root.
49    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    /// Backend-specific installer that produced the binaries.
59    pub provider: NativeToolProvider,
60    /// Exact managed compiler/runtime used by the provider.
61    pub runtime: InstallDependency,
62    /// Content identity for every published executable.
63    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
88/// Stable content identity for a bounded runtime root. Prefer the cached,
89/// layout-aware [`go_runtime_identity`] and [`rust_runtime_identity`] helpers
90/// for managed compiler runtimes.
91pub 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
123/// Content identity for an exact managed Go SDK.
124///
125/// The inventory covers the executable toolchain (`bin` and `pkg/tool`), the
126/// standard-library sources, runtime libraries, and the runtime version/env
127/// files used by `go install`. Symlinks and non-regular payloads fail closed.
128/// An integrity-checked receipt caches file hashes; unchanged path/size/mtime
129/// metadata avoids re-reading the entire SDK during every activation or shim
130/// validation. The receipt itself and other osdk metadata are outside the
131/// payload inventory.
132pub 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
583/// Content identity for the build-critical portion of an exact managed Rust
584/// toolchain. The compiler payload comes from rustup's `manifest-rustc-*`, and
585/// every regular file below the selected target's `lib` directory is included.
586/// Symlinks and non-regular payloads fail closed.
587///
588/// Hashing a target sysroot can read hundreds of MiB, so the managed runtime's
589/// adjacent marker directory holds an atomic receipt. Its full sorted
590/// path/size/mtime inventory is checked on every call; unchanged metadata lets
591/// us reuse the content identity, while any drift triggers a full rehash. The
592/// receipt is an integrity-checked cache for osdk-managed, immutable runtimes,
593/// not a same-user security boundary (a process able to rewrite both payload
594/// timestamps and osdk state is outside this boundary).
595pub 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        // Minimal fixture toolchains and older layouts may omit rustup's
736        // component manifest. Hash the conservative compiler payload instead.
737        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(&current).map_err(|error| Error::io(&current, 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/// Lifecycle state derived entirely from inputs known before installation.
1180#[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/// Result of atomically selecting or preparing one native install identity.
1209#[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    /// Serialize one complete install attempt. A valid existing install is
1297    /// returned directly; otherwise a stage retaining the identity lock is
1298    /// returned so no second provider command can run for the same identity.
1299    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    /// Validate an already-published install. A missing root is not an error; a
1309    /// complete but tampered root is rejected instead of silently rebuilt.
1310    pub fn validate_complete(&self, dirs: &Dirs) -> Result<bool> {
1311        validate_install_candidate(dirs, self.family, self.install_root(), self.identity())
1312    }
1313
1314    /// Backend-hook form of [`Self::validate_complete`].
1315    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    /// Validate a complete install and either reuse it or fail closed.
1328    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    /// Remove stale transaction state after the caller determined that no
1345    /// valid complete install exists. The identity lock is held by `prepare`.
1346    fn discard_incomplete_install(&self) -> Result<()> {
1347        let _ = remove_metadata_seal(&self.locator)?;
1348        Ok(())
1349    }
1350
1351    /// Create a unique, unexposed sibling of the final install directory while
1352    /// retaining the identity lock for the complete transaction.
1353    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    /// Remove exactly this identity-qualified root. The caller must hold the
1417    /// lifecycle lock so install and uninstall cannot race.
1418    pub async fn uninstall(&self) -> Result<bool> {
1419        let _lock = self.acquire_lock().await?;
1420        remove_exact_install(&self.locator)
1421    }
1422}
1423
1424/// A native install staging root that is deleted unless publication succeeds.
1425pub 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    /// Clear provider output before an explicitly permitted fallback while
1454    /// retaining the staging identity and its cross-process lock.
1455    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    /// Validate provider output, write receipt/inventory/completion metadata in
1474    /// that order, and expose the complete tree with one directory rename.
1475    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
1549/// Validate a native candidate without executing its provider or consulting the
1550/// network. This is the single reuse/activation/shim/lock inspection boundary.
1551pub 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    // The configured install root is the trust boundary. Platform-owned
2079    // ancestors may have another spelling (macOS `/var` -> `/private/var`, or
2080    // a Windows 8.3 alias), so create them through the OS and begin strict
2081    // no-link validation at `base` instead of at the filesystem root.
2082    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(&current) {
2095            Ok(metadata) => metadata,
2096            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2097                match std::fs::create_dir(&current) {
2098                    Ok(()) => {}
2099                    Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
2100                    Err(error) => return Err(Error::io(&current, error)),
2101                }
2102                std::fs::symlink_metadata(&current).map_err(|error| Error::io(&current, error))?
2103            }
2104            Err(error) => return Err(Error::io(&current, error)),
2105        };
2106        validate_native_install_directory_metadata(&current, &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        // The attacker can rewrite every file under the install root, but the
2808        // adjacent identity-qualified seal still commits to the original bytes.
2809        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}