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