1use 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
19pub 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#[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#[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
83fn 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#[cfg(unix)]
98const MAX_SUN_PATH_BYTES: usize = 103;
99
100#[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#[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
150fn per_user_server_port() -> u16 {
157 port_for_identity(&user_identity())
158}
159
160fn 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#[cfg(unix)]
180fn user_identity() -> String {
181 nix::unistd::getuid().to_string()
182}
183
184#[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 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 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#[derive(Debug, Clone, Default)]
257pub struct Sccache;
258
259impl Sccache {
260 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 pub async fn is_available(&self, host: &Host) -> bool {
278 self.path(host).await.is_ok()
279 }
280}
281
282const MINIMUM_SCCACHE_VERSION: &str = "0.9.0";
286
287async 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
350async 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 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#[derive(Debug, Clone)]
420pub enum SccacheInstallation {
421 Brew,
423 Winget,
425 PackageManager,
427 Managed(ManagedTool),
430}
431
432#[derive(Debug, thiserror::Error)]
434pub enum FailToInstallSccache {
435 #[error("Homebrew not found. Please install Homebrew to proceed.")]
437 BrewNotFound,
438
439 #[error("Failed to install sccache: {0}")]
441 Command(#[from] CommandError),
442
443 #[error(
445 "winget is required for automatic sccache installation on Windows. Install App Installer and retry."
446 )]
447 WingetNotFound,
448
449 #[error("Failed to install sccache via winget: {0}")]
451 WingetInstallFailed(String),
452
453 #[error(
455 "No supported Linux package manager found (apt-get, dnf, pacman, zypper, apk). Install sccache manually."
456 )]
457 UnsupportedPackageManager,
458
459 #[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 assert_ne!(port_for_identity("0"), port_for_identity("1"));
586 }
587
588 #[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 #[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 #[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 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 #[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 #[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; };
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}