1use 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
19pub 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
50fn 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
58fn 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#[cfg(unix)]
82const MAX_SUN_PATH_BYTES: usize = 103;
83
84#[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#[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
132fn per_user_server_port() -> u16 {
139 port_for_identity(&user_identity())
140}
141
142fn 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#[cfg(unix)]
162fn user_identity() -> String {
163 nix::unistd::getuid().to_string()
164}
165
166#[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 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#[derive(Debug, Clone, Default)]
237pub struct Sccache;
238
239impl Sccache {
240 pub async fn path(&self, host: &Host) -> Result<PathBuf, which::Error> {
245 host.which("sccache").await
246 }
247
248 pub async fn is_available(&self, host: &Host) -> bool {
250 self.path(host).await.is_ok()
251 }
252}
253
254const MINIMUM_SCCACHE_VERSION: &str = "0.9.0";
258
259async 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#[derive(Debug, Clone)]
369pub struct SccacheInstallation;
370
371#[derive(Debug, thiserror::Error)]
373pub enum FailToInstallSccache {
374 #[error("Homebrew not found. Please install Homebrew to proceed.")]
376 BrewNotFound,
377
378 #[error("Failed to install sccache: {0}")]
380 Command(#[from] CommandError),
381
382 #[error(
384 "winget is required for automatic sccache installation on Windows. Install App Installer and retry."
385 )]
386 WingetNotFound,
387
388 #[error("Failed to install sccache via winget: {0}")]
390 WingetInstallFailed(String),
391
392 #[error(
394 "No supported Linux package manager found (apt-get, dnf, pacman, zypper, apk). Install sccache manually."
395 )]
396 UnsupportedPackageManager,
397
398 #[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 assert_ne!(port_for_identity("0"), port_for_identity("1"));
528 }
529
530 #[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 #[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 #[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}