use std::ffi::OsString;
use std::path::{Path, PathBuf};
use eyre::WrapErr as _;
use smol::process::Command;
use crate::{
brew::Brew,
toolchain::linux::{
LinuxPackageManagerError, has_supported_package_manager, install_named_packages,
},
toolchain::winget::{WingetInstallError, ensure_package_installed},
toolchain::{Host, Installation, Toolchain, ToolchainError},
utils::{CommandError, sccache_install_hint, sccache_upgrade_hint},
};
pub fn configure_compilation_cache(command: &mut Command, sccache_path: &Path) -> eyre::Result<()> {
for (key, value) in compilation_cache_env(sccache_path)? {
command.env(key, value);
}
Ok(())
}
fn compilation_cache_env(sccache_path: &Path) -> eyre::Result<Vec<(&'static str, OsString)>> {
let water_home = crate::project_model::water_dir::water_home_dir().ok();
compilation_cache_env_in(sccache_path, water_home.as_deref())
}
fn compilation_cache_env_in(
sccache_path: &Path,
#[cfg_attr(not(unix), allow(unused))] water_home: Option<&Path>,
) -> eyre::Result<Vec<(&'static str, OsString)>> {
let mut env = vec![
("RUSTC_WRAPPER", sccache_path.as_os_str().to_os_string()),
(
"SCCACHE_SERVER_PORT",
per_user_server_port().to_string().into(),
),
];
#[cfg(unix)]
if let Some(socket) = water_home.map(server_socket_path_in).transpose()?.flatten() {
env.push(("SCCACHE_SERVER_UDS", socket.into_os_string()));
}
Ok(env)
}
#[cfg(unix)]
const MAX_SUN_PATH_BYTES: usize = 103;
#[cfg(unix)]
fn server_socket_path_in(water_home: &Path) -> eyre::Result<Option<PathBuf>> {
let socket_dir = water_home.join("sccache");
ensure_private_socket_dir(&socket_dir)?;
let socket = socket_dir.join("server.sock");
Ok((socket.as_os_str().len() <= MAX_SUN_PATH_BYTES).then_some(socket))
}
#[cfg(unix)]
fn ensure_private_socket_dir(dir: &Path) -> eyre::Result<()> {
use std::os::unix::fs::{DirBuilderExt, MetadataExt};
std::fs::DirBuilder::new()
.mode(0o700)
.recursive(true)
.create(dir)
.wrap_err_with(|| format!("Failed to create sccache socket dir {}", dir.display()))?;
let mode = std::fs::metadata(dir)
.wrap_err_with(|| format!("Failed to stat sccache socket dir {}", dir.display()))?
.mode()
& 0o777;
eyre::ensure!(
mode.trailing_zeros() >= 6,
"sccache socket dir {} has mode {mode:o}, wider than 0700 — other local \
accounts could submit compile jobs to this user's sccache server. \
Tighten it with `chmod 700 {}`.",
dir.display(),
dir.display()
);
Ok(())
}
fn per_user_server_port() -> u16 {
port_for_identity(&user_identity())
}
fn port_for_identity(identity: &str) -> u16 {
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = FNV_OFFSET;
for byte in identity.as_bytes() {
hash = (hash ^ u64::from(*byte)).wrapping_mul(FNV_PRIME);
}
22_000 + (hash % 9_151) as u16
}
#[cfg(unix)]
fn user_identity() -> String {
nix::unistd::getuid().to_string()
}
#[cfg(windows)]
fn user_identity() -> String {
use std::io;
use windows_sys::Win32::{
Foundation::{CloseHandle, LocalFree},
Security::{
Authorization::ConvertSidToStringSidW, GetTokenInformation, TOKEN_QUERY, TOKEN_USER,
TokenUser,
},
System::Threading::{GetCurrentProcess, OpenProcessToken},
};
unsafe {
let mut token = std::mem::zeroed();
assert!(
OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) != 0,
"OpenProcessToken failed: {}",
io::Error::last_os_error()
);
let mut size = 0u32;
GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut size);
let mut buffer = vec![0u8; size as usize];
let queried = size > 0
&& GetTokenInformation(
token,
TokenUser,
buffer.as_mut_ptr().cast(),
size,
&mut size,
) != 0;
CloseHandle(token);
assert!(
queried,
"GetTokenInformation(TokenUser) failed: {}",
io::Error::last_os_error()
);
let sid = (*buffer.as_ptr().cast::<TOKEN_USER>()).User.Sid;
let mut text = std::ptr::null_mut::<u16>();
assert!(
ConvertSidToStringSidW(sid, &mut text) != 0,
"ConvertSidToStringSidW failed: {}",
io::Error::last_os_error()
);
let mut length = 0usize;
while *text.add(length) != 0 {
length += 1;
}
let identity = String::from_utf16_lossy(std::slice::from_raw_parts(text, length));
LocalFree(text.cast());
identity
}
}
#[cfg(not(any(unix, windows)))]
compile_error!(
"per-user sccache ports need a user-identity source; supported hosts are unix and Windows"
);
#[derive(Debug, Clone, Default)]
pub struct Sccache;
impl Sccache {
pub async fn path(&self, host: &Host) -> Result<PathBuf, which::Error> {
host.which("sccache").await
}
pub async fn is_available(&self, host: &Host) -> bool {
self.path(host).await.is_ok()
}
}
const MINIMUM_SCCACHE_VERSION: &str = "0.9.0";
async fn check_sccache_version(host: &Host) -> Result<(), ToolchainError<SccacheInstallation>> {
let Ok(output) = host.output("sccache", ["--version"]).await else {
return Err(ToolchainError::unfixable(
"sccache is installed but `sccache --version` could not run",
format!(
"Reinstall sccache ({}) so it executes correctly, then re-run `water doctor`.",
sccache_install_hint()
),
));
};
if !output.status.success() {
return Err(ToolchainError::unfixable(
"`sccache --version` exited with a failure",
format!(
"Reinstall sccache ({}) so `sccache --version` succeeds, then re-run `water doctor`.",
sccache_install_hint()
),
));
}
let text = String::from_utf8_lossy(&output.stdout);
let installed = text
.split_whitespace()
.nth(1)
.and_then(|token| semver::Version::parse(token).ok());
let Some(installed) = installed else {
return Err(ToolchainError::unfixable(
format!(
"`sccache --version` printed an unreadable version: {}",
text.trim()
),
format!(
"Install a released sccache build ({}), then re-run `water doctor`.",
sccache_install_hint()
),
));
};
let minimum =
semver::Version::parse(MINIMUM_SCCACHE_VERSION).expect("the version floor is valid semver");
if installed.cmp_precedence(&minimum).is_lt() {
return Err(ToolchainError::unfixable(
format!(
"sccache {installed} is too old: per-user build-cache isolation needs sccache {MINIMUM_SCCACHE_VERSION} or newer"
),
format!(
"Upgrade sccache — {} — then re-run `water doctor`.",
sccache_upgrade_hint()
),
));
}
Ok(())
}
impl Toolchain for Sccache {
type Installation = SccacheInstallation;
async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
if host.which("sccache").await.is_ok() {
check_sccache_version(host).await
} else if cfg!(target_os = "windows") {
if host.which("winget").await.is_ok() {
Err(ToolchainError::fixable(SccacheInstallation))
} else {
Err(ToolchainError::unfixable(
"sccache not found and winget is unavailable",
format!(
"Install Microsoft App Installer to provide winget, or install manually with {}.",
sccache_install_hint()
),
))
}
} else if cfg!(target_os = "macos") {
if host.which("brew").await.is_ok() {
Err(ToolchainError::fixable(SccacheInstallation))
} else {
Err(ToolchainError::unfixable(
"sccache not found and Homebrew is unavailable",
format!(
"Install Homebrew to enable automatic fixes, or install manually with {}.",
sccache_install_hint()
),
))
}
} else if cfg!(target_os = "linux") {
if has_supported_package_manager(host).await {
Err(ToolchainError::fixable(SccacheInstallation))
} else {
Err(ToolchainError::unfixable(
"sccache is missing and no supported package manager was found",
format!("Install manually with {}", sccache_install_hint()),
))
}
} else {
Err(ToolchainError::unfixable(
"sccache not found",
format!(
"Install sccache manually ({}) and ensure `sccache` is available in PATH.",
sccache_install_hint()
),
))
}
}
}
#[derive(Debug, Clone)]
pub struct SccacheInstallation;
#[derive(Debug, thiserror::Error)]
pub enum FailToInstallSccache {
#[error("Homebrew not found. Please install Homebrew to proceed.")]
BrewNotFound,
#[error("Failed to install sccache: {0}")]
Command(#[from] CommandError),
#[error(
"winget is required for automatic sccache installation on Windows. Install App Installer and retry."
)]
WingetNotFound,
#[error("Failed to install sccache via winget: {0}")]
WingetInstallFailed(String),
#[error(
"No supported Linux package manager found (apt-get, dnf, pacman, zypper, apk). Install sccache manually."
)]
UnsupportedPackageManager,
#[error(
"Automatic installation of sccache is not supported on this platform. \
Install manually with: cargo install sccache"
)]
UnsupportedPlatform,
}
impl Installation for SccacheInstallation {
type Error = FailToInstallSccache;
async fn install(&self, host: &Host) -> Result<(), Self::Error> {
if cfg!(target_os = "macos") {
let brew = Brew::default();
brew.check(host)
.await
.map_err(|_| FailToInstallSccache::BrewNotFound)?;
brew.install(host, "sccache").await?;
Ok(())
} else if cfg!(target_os = "windows") {
ensure_package_installed(host, "Mozilla.sccache")
.await
.map_err(map_winget_error_for_sccache)
} else if cfg!(target_os = "linux") {
install_named_packages(host, &["sccache"])
.await
.map_err(map_linux_error_for_sccache)
} else {
Err(FailToInstallSccache::UnsupportedPlatform)
}
}
}
fn map_linux_error_for_sccache(error: LinuxPackageManagerError) -> FailToInstallSccache {
match error {
LinuxPackageManagerError::UnsupportedPackageManager => {
FailToInstallSccache::UnsupportedPackageManager
}
LinuxPackageManagerError::Command(source) => FailToInstallSccache::Command(source),
}
}
fn map_winget_error_for_sccache(error: WingetInstallError) -> FailToInstallSccache {
match error {
WingetInstallError::WingetNotFound => FailToInstallSccache::WingetNotFound,
WingetInstallError::CommandFailed(err) => {
FailToInstallSccache::WingetInstallFailed(err.to_string())
}
WingetInstallError::NotInstalled { package_id } => {
FailToInstallSccache::WingetInstallFailed(format!(
"Package `{package_id}` is still missing after winget install; verify winget sources and retry."
))
}
}
}
#[cfg(test)]
mod host_tests {
use std::ffi::OsString;
use std::path::Path;
use super::{
Sccache, SccacheInstallation, compilation_cache_env_in, per_user_server_port,
port_for_identity,
};
use crate::toolchain::testing::TestMachine;
use crate::toolchain::{Toolchain, ToolchainError};
fn check(machine: &TestMachine) -> Result<(), ToolchainError<SccacheInstallation>> {
let host = machine.host(Vec::<(String, String)>::new());
smol::block_on(Sccache.check(&host))
}
#[test]
fn ok_when_sccache_on_path() {
let machine = TestMachine::new();
machine.install("sccache");
check(&machine).expect("sccache on PATH must be ok");
}
#[test]
fn sccache_below_the_uds_floor_is_rejected() {
let machine = TestMachine::new();
machine.install("sccache");
let host = machine.host([("WATERUI_FAKE_SCCACHE_VERSION", "0.8.2")]);
let result = smol::block_on(Sccache.check(&host));
let Err(ToolchainError::Unfixable(error)) = result else {
panic!("an sccache below the UDS floor must be unfixable: {result:?}");
};
assert!(
error.message().contains("0.8.2"),
"the error names the installed version: {}",
error.message()
);
assert!(
error.message().contains("0.9.0"),
"the error names the required version: {}",
error.message()
);
}
#[test]
fn sccache_with_unreadable_version_is_rejected() {
let machine = TestMachine::new();
machine.install("sccache");
let host = machine.host([("WATERUI_FAKE_SCCACHE_VERSION", "unknown")]);
let result = smol::block_on(Sccache.check(&host));
assert!(
matches!(result, Err(ToolchainError::Unfixable(_))),
"an sccache whose version cannot be read must be unfixable: {result:?}"
);
}
#[test]
fn port_is_deterministic_and_inside_the_reserved_block() {
let port = per_user_server_port();
assert_eq!(port, per_user_server_port());
assert!(
(22_000..=31_150).contains(&port),
"the port stays below every host's ephemeral floor: {port}"
);
}
#[test]
fn distinct_identities_land_on_distinct_ports() {
assert_ne!(port_for_identity("0"), port_for_identity("1"));
}
#[test]
fn compilation_cache_env_sets_wrapper_port_and_unix_socket() {
let water_home = tempfile::tempdir().expect("water home");
let env =
compilation_cache_env_in(Path::new("/toolchain/bin/sccache"), Some(water_home.path()))
.expect("a scratch Water home yields the env");
assert!(
env.contains(&("RUSTC_WRAPPER", OsString::from("/toolchain/bin/sccache"))),
"RUSTC_WRAPPER routes rustc through sccache: {env:?}"
);
let port = env
.iter()
.find(|(key, _)| *key == "SCCACHE_SERVER_PORT")
.map(|(_, value)| {
value
.to_str()
.expect("port is text")
.parse::<u16>()
.expect("port parses")
})
.expect("SCCACHE_SERVER_PORT is always set");
assert!((22_000..=31_150).contains(&port));
#[cfg(unix)]
{
let socket = env
.iter()
.find(|(key, _)| *key == "SCCACHE_SERVER_UDS")
.map(|(_, value)| value.to_string_lossy().into_owned())
.expect("unix builds get the per-user socket");
assert!(
socket.ends_with("sccache/server.sock"),
"the socket lives in a private dir under the Water home: {socket}"
);
assert!(
socket.starts_with(&water_home.path().display().to_string()),
"the socket lives under the injected Water home: {socket}"
);
}
#[cfg(not(unix))]
assert!(
!env.iter().any(|(key, _)| *key == "SCCACHE_SERVER_UDS"),
"non-unix builds only get the port"
);
}
#[cfg(unix)]
#[test]
fn oversized_home_path_falls_back_to_port_only() {
let long_home = tempfile::tempdir()
.expect("water home")
.path()
.join("a".repeat(200));
assert!(
super::server_socket_path_in(&long_home)
.expect("creatable but overlong home")
.is_none()
);
let home = tempfile::tempdir().expect("water home");
let socket = super::server_socket_path_in(&home.path().join(".water"))
.expect("a normal Water home gets a socket")
.expect("a normal Water home gets a socket");
assert!(socket.ends_with("sccache/server.sock"));
assert!(
socket
.parent()
.and_then(Path::parent)
.is_some_and(|dir| dir.ends_with(".water")),
"the socket's parent dir sits directly under the Water home: {}",
socket.display()
);
}
#[cfg(unix)]
#[test]
fn a_socket_dir_wider_than_private_is_rejected() {
use std::os::unix::fs::PermissionsExt as _;
let home = tempfile::tempdir().expect("water home");
let socket_dir = home.path().join("sccache");
std::fs::create_dir(&socket_dir).expect("socket dir");
std::fs::set_permissions(&socket_dir, std::fs::Permissions::from_mode(0o755))
.expect("chmod socket dir");
let error = super::server_socket_path_in(home.path())
.expect_err("a world-traversable socket dir must be rejected");
assert!(
error.to_string().contains("0755") || error.to_string().contains("755"),
"the error names the offending mode: {error}"
);
std::fs::set_permissions(&socket_dir, std::fs::Permissions::from_mode(0o700))
.expect("tighten socket dir");
super::server_socket_path_in(home.path())
.expect("a 0700 socket dir is accepted")
.expect("a 0700 socket dir yields a socket");
}
#[test]
fn missing_without_installer_is_unfixable() {
let machine = TestMachine::new();
let result = check(&machine);
assert!(
matches!(result, Err(ToolchainError::Unfixable(_))),
"missing sccache without a package manager must be unfixable: {result:?}"
);
}
#[test]
fn missing_with_installer_is_fixable() {
let machine = TestMachine::new();
#[cfg(target_os = "macos")]
machine.install("brew");
#[cfg(target_os = "linux")]
machine.install("apt-get");
#[cfg(target_os = "windows")]
machine.install("winget");
let result = check(&machine);
assert!(
matches!(result, Err(ToolchainError::Fixable(_))),
"missing sccache with a package manager must be fixable: {result:?}"
);
}
}