Skip to main content

waterui_cli/toolchain/
sccache.rs

1//! Toolchain support for `sccache` - shared compilation cache.
2
3use std::ffi::OsString;
4use std::path::{Path, PathBuf};
5
6use eyre::WrapErr as _;
7use smol::process::Command;
8
9use crate::{
10    brew::Brew,
11    toolchain::linux::{
12        LinuxPackageManagerError, has_supported_package_manager, install_named_packages,
13    },
14    toolchain::winget::{WingetInstallError, ensure_package_installed},
15    toolchain::{Host, Installation, Toolchain, ToolchainError},
16    utils::{CommandError, sccache_install_hint, sccache_upgrade_hint},
17};
18
19/// Route a Cargo invocation's compiles through `sccache`.
20///
21/// Caching only bites because generated-crate builds also disable incremental
22/// compilation — Cargo does not pass `-C incremental` to registry dependencies but does
23/// pass it to every *path* dependency, which for a `WaterUI` build is the entire
24/// framework, and `sccache` refuses to cache an incremental compile. That setting lives
25/// in [`crate::build::configure_generated_crate_compilation`] rather than here, because
26/// it must not depend on whether a machine happens to have `sccache` installed: it
27/// changes the compiled ABI, and two builds in one flow have to agree on it.
28///
29/// The server address is namespaced to the invoking user. sccache discovers
30/// its server on a host-wide address — TCP `127.0.0.1:4226` unless told
31/// otherwise — and every compile job runs inside the server process under
32/// the *server owner's* identity. Left at the default, a build running as one
33/// user borrows a server another user left alive and its artifacts land in
34/// this user's target dir owned by the other uid, ending the build on
35/// `Permission denied`. A unix socket under the user's own Water home gives
36/// each account its own server with no port to collide over, and sccache
37/// ≥ 0.9.0 prefers it when both are set; the port is still set unconditionally
38/// because older builds ignore the socket variable entirely and would fall
39/// back to the shared default address.
40/// # Errors
41/// Returns an error when the socket directory under the user's Water home
42/// cannot be created or exists with permissions wider than `0700`.
43pub fn configure_compilation_cache(command: &mut Command, sccache_path: &Path) -> eyre::Result<()> {
44    for (key, value) in compilation_cache_env(sccache_path)? {
45        command.env(key, value);
46    }
47    Ok(())
48}
49
50/// The environment a compile command needs for per-user sccache routing, as
51/// `(key, value)` pairs so the whole contract is observable without spawning
52/// a process.
53fn compilation_cache_env(sccache_path: &Path) -> eyre::Result<Vec<(&'static str, OsString)>> {
54    let water_home = crate::project_model::water_dir::water_home_dir().ok();
55    compilation_cache_env_in(sccache_path, water_home.as_deref())
56}
57
58/// `compilation_cache_env` with the Water home supplied — tests inject a
59/// scratch directory so the contract is observable without touching the real
60/// `~/.water` or depending on the machine's home-path length.
61fn compilation_cache_env_in(
62    sccache_path: &Path,
63    #[cfg_attr(not(unix), allow(unused))] water_home: Option<&Path>,
64) -> eyre::Result<Vec<(&'static str, OsString)>> {
65    let mut env = vec![
66        ("RUSTC_WRAPPER", sccache_path.as_os_str().to_os_string()),
67        (
68            "SCCACHE_SERVER_PORT",
69            per_user_server_port().to_string().into(),
70        ),
71    ];
72    #[cfg(unix)]
73    if let Some(socket) = water_home.map(server_socket_path_in).transpose()?.flatten() {
74        env.push(("SCCACHE_SERVER_UDS", socket.into_os_string()));
75    }
76    Ok(env)
77}
78
79/// `sun_path` is 108 bytes on Linux and 104 on macOS/BSD, including the
80/// terminator — 103 keeps a socket path bindable on every unix host.
81#[cfg(unix)]
82const MAX_SUN_PATH_BYTES: usize = 103;
83
84/// The unix socket a per-user sccache server listens on, under a dedicated
85/// `0700` directory in the invoking user's Water home so no other account can
86/// reach — or be reached by — it. `Ok(None)` when the path would not fit
87/// `sun_path`: a socket that cannot bind is no fallback at all, so only the
88/// per-user port is offered then.
89///
90/// # Errors
91/// Returns an error when the socket directory cannot be created, or exists
92/// with permissions wider than `0700` — sccache's server runs compile jobs
93/// under its owner's identity with no authentication, so a socket another
94/// account could traverse to is not an isolation mechanism and the build must
95/// not silently fall back to the shared-address exposure.
96#[cfg(unix)]
97fn server_socket_path_in(water_home: &Path) -> eyre::Result<Option<PathBuf>> {
98    let socket_dir = water_home.join("sccache");
99    ensure_private_socket_dir(&socket_dir)?;
100    let socket = socket_dir.join("server.sock");
101    Ok((socket.as_os_str().len() <= MAX_SUN_PATH_BYTES).then_some(socket))
102}
103
104/// Create `dir` mode `0700`, or verify an existing one is that private. A
105/// wider directory fails loudly: the socket inside is how one account would
106/// submit compile jobs to another user's server, so narrowing the check to a
107/// warning would leave the door it exists to close.
108#[cfg(unix)]
109fn ensure_private_socket_dir(dir: &Path) -> eyre::Result<()> {
110    use std::os::unix::fs::{DirBuilderExt, MetadataExt};
111
112    std::fs::DirBuilder::new()
113        .mode(0o700)
114        .recursive(true)
115        .create(dir)
116        .wrap_err_with(|| format!("Failed to create sccache socket dir {}", dir.display()))?;
117    let mode = std::fs::metadata(dir)
118        .wrap_err_with(|| format!("Failed to stat sccache socket dir {}", dir.display()))?
119        .mode()
120        & 0o777;
121    eyre::ensure!(
122        mode.trailing_zeros() >= 6,
123        "sccache socket dir {} has mode {mode:o}, wider than 0700 — other local \
124         accounts could submit compile jobs to this user's sccache server. \
125         Tighten it with `chmod 700 {}`.",
126        dir.display(),
127        dir.display()
128    );
129    Ok(())
130}
131
132/// A deterministic per-user TCP port for the sccache server, in the
133/// 22000–31150 block below every supported host's ephemeral floor (Linux
134/// 32768, Windows and macOS 49152) so a transient connection never occupies
135/// it. A collision with an unrelated registered service is still possible;
136/// that fails the server bind loudly instead of quietly joining another
137/// user's server.
138fn per_user_server_port() -> u16 {
139    port_for_identity(&user_identity())
140}
141
142/// Spread a machine-unique user identity over the port block. FNV-1a needs
143/// no state and no coordination between accounts.
144fn port_for_identity(identity: &str) -> u16 {
145    const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
146    const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
147    let mut hash = FNV_OFFSET;
148    for byte in identity.as_bytes() {
149        hash = (hash ^ u64::from(*byte)).wrapping_mul(FNV_PRIME);
150    }
151    22_000 + (hash % 9_151) as u16
152}
153
154/// The machine-unique identity of the invoking user. Hashing the user *name*
155/// instead would let two accounts share a port — `ayy` and `cad` both
156/// produced 46119 — and a name that cannot be read would pin every such
157/// machine to one port. The uid is always present and distinct per account;
158/// the FNV-1a reduction into 9151 slots can still map two uids to one port —
159/// rare, and it re-shares a server rather than failing, so it is worth
160/// keeping the identity as distinct as the OS makes possible.
161#[cfg(unix)]
162fn user_identity() -> String {
163    nix::unistd::getuid().to_string()
164}
165
166/// The machine-unique identity of the invoking user: the account's SID string
167/// (`S-1-5-21-…`), which is unique per machine and always present for a
168/// running process.
169#[cfg(windows)]
170fn user_identity() -> String {
171    use std::io;
172
173    use windows_sys::Win32::{
174        Foundation::{CloseHandle, LocalFree},
175        Security::{
176            Authorization::ConvertSidToStringSidW, GetTokenInformation, TOKEN_QUERY, TOKEN_USER,
177            TokenUser,
178        },
179        System::Threading::{GetCurrentProcess, OpenProcessToken},
180    };
181
182    // SAFETY: every call queries the current process's own token; the token
183    // buffer is sized by the API before the second `GetTokenInformation`
184    // writes it, the handle is closed on every path past `OpenProcessToken`,
185    // and the string the SID conversion allocates is freed with `LocalFree`.
186    unsafe {
187        let mut token = std::mem::zeroed();
188        assert!(
189            OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) != 0,
190            "OpenProcessToken failed: {}",
191            io::Error::last_os_error()
192        );
193        let mut size = 0u32;
194        GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut size);
195        let mut buffer = vec![0u8; size as usize];
196        let queried = size > 0
197            && GetTokenInformation(
198                token,
199                TokenUser,
200                buffer.as_mut_ptr().cast(),
201                size,
202                &mut size,
203            ) != 0;
204        CloseHandle(token);
205        assert!(
206            queried,
207            "GetTokenInformation(TokenUser) failed: {}",
208            io::Error::last_os_error()
209        );
210        let sid = (*buffer.as_ptr().cast::<TOKEN_USER>()).User.Sid;
211        let mut text = std::ptr::null_mut::<u16>();
212        assert!(
213            ConvertSidToStringSidW(sid, &mut text) != 0,
214            "ConvertSidToStringSidW failed: {}",
215            io::Error::last_os_error()
216        );
217        let mut length = 0usize;
218        while *text.add(length) != 0 {
219            length += 1;
220        }
221        let identity = String::from_utf16_lossy(std::slice::from_raw_parts(text, length));
222        LocalFree(text.cast());
223        identity
224    }
225}
226
227#[cfg(not(any(unix, windows)))]
228compile_error!(
229    "per-user sccache ports need a user-identity source; supported hosts are unix and Windows"
230);
231
232/// Toolchain for `sccache` - a shared compilation cache for Rust.
233///
234/// sccache is optional but significantly improves build times by caching
235/// compiled artifacts across builds and projects.
236#[derive(Debug, Clone, Default)]
237pub struct Sccache;
238
239impl Sccache {
240    /// Get the path to the `sccache` executable if available.
241    ///
242    /// # Errors
243    /// Returns an error if `sccache` is not found in the system PATH.
244    pub async fn path(&self, host: &Host) -> Result<PathBuf, which::Error> {
245        host.which("sccache").await
246    }
247
248    /// Check if sccache is available on `host` without returning an error.
249    pub async fn is_available(&self, host: &Host) -> bool {
250        self.path(host).await.is_ok()
251    }
252}
253
254/// The sccache release that understands `SCCACHE_SERVER_UDS` — the mechanism
255/// `configure_compilation_cache` uses to keep each user's compile server
256/// private on unix hosts.
257const MINIMUM_SCCACHE_VERSION: &str = "0.9.0";
258
259/// `sccache` is on PATH; it still has to be new enough to honor the per-user
260/// server address the compile path hands it, which only 0.9.0 does. An older
261/// build gets the port fallback and keeps working, but a check that cannot
262/// name the installed version — or finds one below the floor — reports it
263/// instead of letting a quietly-shared host-wide server resurface.
264async fn check_sccache_version(host: &Host) -> Result<(), ToolchainError<SccacheInstallation>> {
265    let Ok(output) = host.output("sccache", ["--version"]).await else {
266        return Err(ToolchainError::unfixable(
267            "sccache is installed but `sccache --version` could not run",
268            format!(
269                "Reinstall sccache ({}) so it executes correctly, then re-run `water doctor`.",
270                sccache_install_hint()
271            ),
272        ));
273    };
274    if !output.status.success() {
275        return Err(ToolchainError::unfixable(
276            "`sccache --version` exited with a failure",
277            format!(
278                "Reinstall sccache ({}) so `sccache --version` succeeds, then re-run `water doctor`.",
279                sccache_install_hint()
280            ),
281        ));
282    }
283    let text = String::from_utf8_lossy(&output.stdout);
284    let installed = text
285        .split_whitespace()
286        .nth(1)
287        .and_then(|token| semver::Version::parse(token).ok());
288    let Some(installed) = installed else {
289        return Err(ToolchainError::unfixable(
290            format!(
291                "`sccache --version` printed an unreadable version: {}",
292                text.trim()
293            ),
294            format!(
295                "Install a released sccache build ({}), then re-run `water doctor`.",
296                sccache_install_hint()
297            ),
298        ));
299    };
300    let minimum =
301        semver::Version::parse(MINIMUM_SCCACHE_VERSION).expect("the version floor is valid semver");
302    if installed.cmp_precedence(&minimum).is_lt() {
303        return Err(ToolchainError::unfixable(
304            format!(
305                "sccache {installed} is too old: per-user build-cache isolation needs sccache {MINIMUM_SCCACHE_VERSION} or newer"
306            ),
307            format!(
308                "Upgrade sccache — {} — then re-run `water doctor`.",
309                sccache_upgrade_hint()
310            ),
311        ));
312    }
313    Ok(())
314}
315
316impl Toolchain for Sccache {
317    type Installation = SccacheInstallation;
318
319    async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
320        if host.which("sccache").await.is_ok() {
321            check_sccache_version(host).await
322        } else if cfg!(target_os = "windows") {
323            if host.which("winget").await.is_ok() {
324                Err(ToolchainError::fixable(SccacheInstallation))
325            } else {
326                Err(ToolchainError::unfixable(
327                    "sccache not found and winget is unavailable",
328                    format!(
329                        "Install Microsoft App Installer to provide winget, or install manually with {}.",
330                        sccache_install_hint()
331                    ),
332                ))
333            }
334        } else if cfg!(target_os = "macos") {
335            if host.which("brew").await.is_ok() {
336                Err(ToolchainError::fixable(SccacheInstallation))
337            } else {
338                Err(ToolchainError::unfixable(
339                    "sccache not found and Homebrew is unavailable",
340                    format!(
341                        "Install Homebrew to enable automatic fixes, or install manually with {}.",
342                        sccache_install_hint()
343                    ),
344                ))
345            }
346        } else if cfg!(target_os = "linux") {
347            if has_supported_package_manager(host).await {
348                Err(ToolchainError::fixable(SccacheInstallation))
349            } else {
350                Err(ToolchainError::unfixable(
351                    "sccache is missing and no supported package manager was found",
352                    format!("Install manually with {}", sccache_install_hint()),
353                ))
354            }
355        } else {
356            Err(ToolchainError::unfixable(
357                "sccache not found",
358                format!(
359                    "Install sccache manually ({}) and ensure `sccache` is available in PATH.",
360                    sccache_install_hint()
361                ),
362            ))
363        }
364    }
365}
366
367/// Installation plan for `sccache`.
368#[derive(Debug, Clone)]
369pub struct SccacheInstallation;
370
371/// Errors that can occur during `sccache` installation.
372#[derive(Debug, thiserror::Error)]
373pub enum FailToInstallSccache {
374    /// Homebrew not found error.
375    #[error("Homebrew not found. Please install Homebrew to proceed.")]
376    BrewNotFound,
377
378    /// An installation command failed.
379    #[error("Failed to install sccache: {0}")]
380    Command(#[from] CommandError),
381
382    /// winget is required for Windows automatic installation.
383    #[error(
384        "winget is required for automatic sccache installation on Windows. Install App Installer and retry."
385    )]
386    WingetNotFound,
387
388    /// Windows installation via winget failed.
389    #[error("Failed to install sccache via winget: {0}")]
390    WingetInstallFailed(String),
391
392    /// Linux package manager is required for automatic installation.
393    #[error(
394        "No supported Linux package manager found (apt-get, dnf, pacman, zypper, apk). Install sccache manually."
395    )]
396    UnsupportedPackageManager,
397
398    /// Unsupported platform error.
399    #[error(
400        "Automatic installation of sccache is not supported on this platform. \
401         Install manually with: cargo install sccache"
402    )]
403    UnsupportedPlatform,
404}
405
406impl Installation for SccacheInstallation {
407    type Error = FailToInstallSccache;
408
409    async fn install(&self, host: &Host) -> Result<(), Self::Error> {
410        if cfg!(target_os = "macos") {
411            let brew = Brew::default();
412
413            brew.check(host)
414                .await
415                .map_err(|_| FailToInstallSccache::BrewNotFound)?;
416            brew.install(host, "sccache").await?;
417
418            Ok(())
419        } else if cfg!(target_os = "windows") {
420            ensure_package_installed(host, "Mozilla.sccache")
421                .await
422                .map_err(map_winget_error_for_sccache)
423        } else if cfg!(target_os = "linux") {
424            install_named_packages(host, &["sccache"])
425                .await
426                .map_err(map_linux_error_for_sccache)
427        } else {
428            Err(FailToInstallSccache::UnsupportedPlatform)
429        }
430    }
431}
432
433fn map_linux_error_for_sccache(error: LinuxPackageManagerError) -> FailToInstallSccache {
434    match error {
435        LinuxPackageManagerError::UnsupportedPackageManager => {
436            FailToInstallSccache::UnsupportedPackageManager
437        }
438        LinuxPackageManagerError::Command(source) => FailToInstallSccache::Command(source),
439    }
440}
441
442fn map_winget_error_for_sccache(error: WingetInstallError) -> FailToInstallSccache {
443    match error {
444        WingetInstallError::WingetNotFound => FailToInstallSccache::WingetNotFound,
445        WingetInstallError::CommandFailed(err) => {
446            FailToInstallSccache::WingetInstallFailed(err.to_string())
447        }
448        WingetInstallError::NotInstalled { package_id } => {
449            FailToInstallSccache::WingetInstallFailed(format!(
450                "Package `{package_id}` is still missing after winget install; verify winget sources and retry."
451            ))
452        }
453    }
454}
455
456#[cfg(test)]
457mod host_tests {
458    use std::ffi::OsString;
459    use std::path::Path;
460
461    use super::{
462        Sccache, SccacheInstallation, compilation_cache_env_in, per_user_server_port,
463        port_for_identity,
464    };
465    use crate::toolchain::testing::TestMachine;
466    use crate::toolchain::{Toolchain, ToolchainError};
467
468    fn check(machine: &TestMachine) -> Result<(), ToolchainError<SccacheInstallation>> {
469        let host = machine.host(Vec::<(String, String)>::new());
470        smol::block_on(Sccache.check(&host))
471    }
472
473    #[test]
474    fn ok_when_sccache_on_path() {
475        let machine = TestMachine::new();
476        machine.install("sccache");
477        check(&machine).expect("sccache on PATH must be ok");
478    }
479
480    #[test]
481    fn sccache_below_the_uds_floor_is_rejected() {
482        let machine = TestMachine::new();
483        machine.install("sccache");
484        let host = machine.host([("WATERUI_FAKE_SCCACHE_VERSION", "0.8.2")]);
485        let result = smol::block_on(Sccache.check(&host));
486        let Err(ToolchainError::Unfixable(error)) = result else {
487            panic!("an sccache below the UDS floor must be unfixable: {result:?}");
488        };
489        assert!(
490            error.message().contains("0.8.2"),
491            "the error names the installed version: {}",
492            error.message()
493        );
494        assert!(
495            error.message().contains("0.9.0"),
496            "the error names the required version: {}",
497            error.message()
498        );
499    }
500
501    #[test]
502    fn sccache_with_unreadable_version_is_rejected() {
503        let machine = TestMachine::new();
504        machine.install("sccache");
505        let host = machine.host([("WATERUI_FAKE_SCCACHE_VERSION", "unknown")]);
506        let result = smol::block_on(Sccache.check(&host));
507        assert!(
508            matches!(result, Err(ToolchainError::Unfixable(_))),
509            "an sccache whose version cannot be read must be unfixable: {result:?}"
510        );
511    }
512
513    #[test]
514    fn port_is_deterministic_and_inside_the_reserved_block() {
515        let port = per_user_server_port();
516        assert_eq!(port, per_user_server_port());
517        assert!(
518            (22_000..=31_150).contains(&port),
519            "the port stays below every host's ephemeral floor: {port}"
520        );
521    }
522
523    #[test]
524    fn distinct_identities_land_on_distinct_ports() {
525        // 0 and 1 are the two uids that exist on every unix host; the names
526        // that used to feed this hash (`ayy`/`cad`) collided.
527        assert_ne!(port_for_identity("0"), port_for_identity("1"));
528    }
529
530    /// The environment contract: `RUSTC_WRAPPER` routes compiles through
531    /// sccache, the port is always set — sccache < 0.9.0 knows nothing else —
532    /// and unix additionally gets the socket that newer builds prefer. The
533    /// Water home is injected so the test never touches the real `~/.water`
534    /// or depends on this machine's home-path length.
535    #[test]
536    fn compilation_cache_env_sets_wrapper_port_and_unix_socket() {
537        let water_home = tempfile::tempdir().expect("water home");
538        let env =
539            compilation_cache_env_in(Path::new("/toolchain/bin/sccache"), Some(water_home.path()))
540                .expect("a scratch Water home yields the env");
541
542        assert!(
543            env.contains(&("RUSTC_WRAPPER", OsString::from("/toolchain/bin/sccache"))),
544            "RUSTC_WRAPPER routes rustc through sccache: {env:?}"
545        );
546        let port = env
547            .iter()
548            .find(|(key, _)| *key == "SCCACHE_SERVER_PORT")
549            .map(|(_, value)| {
550                value
551                    .to_str()
552                    .expect("port is text")
553                    .parse::<u16>()
554                    .expect("port parses")
555            })
556            .expect("SCCACHE_SERVER_PORT is always set");
557        assert!((22_000..=31_150).contains(&port));
558
559        #[cfg(unix)]
560        {
561            let socket = env
562                .iter()
563                .find(|(key, _)| *key == "SCCACHE_SERVER_UDS")
564                .map(|(_, value)| value.to_string_lossy().into_owned())
565                .expect("unix builds get the per-user socket");
566            assert!(
567                socket.ends_with("sccache/server.sock"),
568                "the socket lives in a private dir under the Water home: {socket}"
569            );
570            assert!(
571                socket.starts_with(&water_home.path().display().to_string()),
572                "the socket lives under the injected Water home: {socket}"
573            );
574        }
575        #[cfg(not(unix))]
576        assert!(
577            !env.iter().any(|(key, _)| *key == "SCCACHE_SERVER_UDS"),
578            "non-unix builds only get the port"
579        );
580    }
581
582    /// A socket path that cannot fit `sun_path` must not produce a socket
583    /// that fails to bind — the port then carries the whole contract.
584    #[cfg(unix)]
585    #[test]
586    fn oversized_home_path_falls_back_to_port_only() {
587        let long_home = tempfile::tempdir()
588            .expect("water home")
589            .path()
590            .join("a".repeat(200));
591        assert!(
592            super::server_socket_path_in(&long_home)
593                .expect("creatable but overlong home")
594                .is_none()
595        );
596
597        let home = tempfile::tempdir().expect("water home");
598        let socket = super::server_socket_path_in(&home.path().join(".water"))
599            .expect("a normal Water home gets a socket")
600            .expect("a normal Water home gets a socket");
601        assert!(socket.ends_with("sccache/server.sock"));
602        assert!(
603            socket
604                .parent()
605                .and_then(Path::parent)
606                .is_some_and(|dir| dir.ends_with(".water")),
607            "the socket's parent dir sits directly under the Water home: {}",
608            socket.display()
609        );
610    }
611
612    /// A socket dir another account can traverse is the exact exposure the
613    /// mechanism exists to close — an existing `sccache/` wider than `0700`
614    /// must fail rather than quietly offer the socket.
615    #[cfg(unix)]
616    #[test]
617    fn a_socket_dir_wider_than_private_is_rejected() {
618        use std::os::unix::fs::PermissionsExt as _;
619
620        let home = tempfile::tempdir().expect("water home");
621        let socket_dir = home.path().join("sccache");
622        std::fs::create_dir(&socket_dir).expect("socket dir");
623        std::fs::set_permissions(&socket_dir, std::fs::Permissions::from_mode(0o755))
624            .expect("chmod socket dir");
625
626        let error = super::server_socket_path_in(home.path())
627            .expect_err("a world-traversable socket dir must be rejected");
628        assert!(
629            error.to_string().contains("0755") || error.to_string().contains("755"),
630            "the error names the offending mode: {error}"
631        );
632
633        std::fs::set_permissions(&socket_dir, std::fs::Permissions::from_mode(0o700))
634            .expect("tighten socket dir");
635        super::server_socket_path_in(home.path())
636            .expect("a 0700 socket dir is accepted")
637            .expect("a 0700 socket dir yields a socket");
638    }
639
640    #[test]
641    fn missing_without_installer_is_unfixable() {
642        let machine = TestMachine::new();
643        let result = check(&machine);
644        assert!(
645            matches!(result, Err(ToolchainError::Unfixable(_))),
646            "missing sccache without a package manager must be unfixable: {result:?}"
647        );
648    }
649
650    #[test]
651    fn missing_with_installer_is_fixable() {
652        let machine = TestMachine::new();
653        #[cfg(target_os = "macos")]
654        machine.install("brew");
655        #[cfg(target_os = "linux")]
656        machine.install("apt-get");
657        #[cfg(target_os = "windows")]
658        machine.install("winget");
659        let result = check(&machine);
660        assert!(
661            matches!(result, Err(ToolchainError::Fixable(_))),
662            "missing sccache with a package manager must be fixable: {result:?}"
663        );
664    }
665}