1#![allow(clippy::missing_errors_doc)]
2
3use std::path::Path;
4use zccache_core::NormalizedPath;
5
6#[cfg(feature = "python")]
7mod python;
8
9pub use zccache_download_client::{
10 ArchiveFormat, DownloadSource, FetchRequest, FetchResult, FetchState, FetchStateKind,
11 FetchStatus, WaitMode,
12};
13
14#[derive(Debug, Clone)]
15pub struct InoConvertOptions {
16 pub clang_args: Vec<String>,
17 pub inject_arduino_include: bool,
18}
19
20impl Default for InoConvertOptions {
21 fn default() -> Self {
22 Self {
23 clang_args: Vec::new(),
24 inject_arduino_include: true,
25 }
26 }
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct InoConvertResult {
31 pub cache_hit: bool,
32 pub skipped_write: bool,
33}
34
35#[derive(Debug, Clone)]
36pub struct DownloadParams {
37 pub source: DownloadSource,
38 pub archive_path: Option<std::path::PathBuf>,
39 pub unarchive_path: Option<std::path::PathBuf>,
40 pub expected_sha256: Option<String>,
41 pub archive_format: ArchiveFormat,
42 pub max_connections: Option<usize>,
43 pub min_segment_size: Option<u64>,
44 pub wait_mode: WaitMode,
45 pub dry_run: bool,
46 pub force: bool,
47}
48
49impl DownloadParams {
50 #[must_use]
51 pub fn new(source: impl Into<DownloadSource>) -> Self {
52 Self {
53 source: source.into(),
54 archive_path: None,
55 unarchive_path: None,
56 expected_sha256: None,
57 archive_format: ArchiveFormat::Auto,
58 max_connections: None,
59 min_segment_size: None,
60 wait_mode: WaitMode::Block,
61 dry_run: false,
62 force: false,
63 }
64 }
65}
66
67pub fn run_ino_convert_cached(
68 input: &Path,
69 output: &Path,
70 options: &InoConvertOptions,
71) -> Result<InoConvertResult, Box<dyn std::error::Error>> {
72 let input_hash = zccache_hash::hash_file(input)?;
73 let mut hasher = zccache_hash::StreamHasher::new();
74 hasher.update(b"zccache-ino-convert-v1");
75 hasher.update(input_hash.as_bytes());
76 hasher.update(input.as_os_str().to_string_lossy().as_bytes());
77 hasher.update(if options.inject_arduino_include {
78 b"include-arduino-h"
79 } else {
80 b"no-arduino-h"
81 });
82 if let Some(libclang_hash) = zccache_compiler::arduino::libclang_hash() {
83 hasher.update(libclang_hash.as_bytes());
84 }
85 for arg in &options.clang_args {
86 hasher.update(arg.as_bytes());
87 hasher.update(b"\0");
88 }
89 let cache_key = hasher.finalize().to_hex();
90
91 let cache_dir = zccache_core::config::default_cache_dir().join("ino");
92 std::fs::create_dir_all(&cache_dir)?;
93 let cached_cpp = cache_dir.join(format!("{cache_key}.ino.cpp"));
94
95 if cached_cpp.exists() {
96 return restore_cached_ino_output(&cached_cpp, output);
97 }
98
99 let generated = zccache_compiler::arduino::generate_ino_cpp(
100 input,
101 &zccache_compiler::arduino::ArduinoConversionOptions {
102 clang_args: options.clang_args.clone(),
103 inject_arduino_include: options.inject_arduino_include,
104 },
105 )?;
106
107 write_file_atomically(&cached_cpp, generated.cpp.as_bytes())?;
108 restore_cached_ino_output(&cached_cpp, output).map(|_| InoConvertResult {
109 cache_hit: false,
110 skipped_write: false,
111 })
112}
113
114fn restore_cached_ino_output(
115 cached_cpp: &Path,
116 output: &Path,
117) -> Result<InoConvertResult, Box<dyn std::error::Error>> {
118 if output.exists() {
119 let output_hash = zccache_hash::hash_file(output)?;
120 let cached_hash = zccache_hash::hash_file(cached_cpp)?;
121 if output_hash == cached_hash {
122 return Ok(InoConvertResult {
123 cache_hit: true,
124 skipped_write: true,
125 });
126 }
127 }
128
129 if let Some(parent) = output.parent() {
130 std::fs::create_dir_all(parent)?;
131 }
132 std::fs::copy(cached_cpp, output)?;
133 Ok(InoConvertResult {
134 cache_hit: true,
135 skipped_write: false,
136 })
137}
138
139fn write_file_atomically(path: &Path, data: &[u8]) -> Result<(), std::io::Error> {
140 let parent = path.parent().unwrap_or_else(|| Path::new("."));
141 std::fs::create_dir_all(parent)?;
142
143 let tmp = tempfile::NamedTempFile::new_in(parent)?;
144 std::fs::write(tmp.path(), data)?;
145 match tmp.persist(path) {
146 Ok(_) => Ok(()),
147 Err(err) => Err(err.error),
148 }
149}
150
151fn resolve_endpoint(explicit: Option<&str>) -> String {
152 if let Some(ep) = explicit {
153 return ep.to_string();
154 }
155 if let Ok(ep) = std::env::var("ZCCACHE_ENDPOINT") {
156 return ep;
157 }
158 zccache_ipc::default_endpoint()
159}
160
161pub fn infer_download_archive_path(
162 source: &DownloadSource,
163 archive_format: ArchiveFormat,
164) -> std::path::PathBuf {
165 let file_name = infer_download_file_name(source, archive_format);
166 zccache_core::config::default_cache_dir()
167 .join("downloads")
168 .join("artifacts")
169 .join(file_name)
170 .into_path_buf()
171}
172
173#[must_use]
174pub fn build_download_request(params: DownloadParams) -> FetchRequest {
175 let archive_path = params
176 .archive_path
177 .unwrap_or_else(|| infer_download_archive_path(¶ms.source, params.archive_format));
178 let mut request = FetchRequest::new(params.source, archive_path);
179 request.destination_path_expanded = params.unarchive_path;
180 request.expected_sha256 = params.expected_sha256;
181 request.archive_format = params.archive_format;
182 request.wait_mode = params.wait_mode;
183 request.dry_run = params.dry_run;
184 request.force = params.force;
185 request.download_options.force = params.force;
186 request.download_options.max_connections = params.max_connections;
187 request.download_options.min_segment_size = params.min_segment_size;
188 request
189}
190
191pub fn client_download(
192 endpoint: Option<&str>,
193 params: DownloadParams,
194) -> Result<FetchResult, String> {
195 let request = build_download_request(params);
196 let client = zccache_download_client::DownloadClient::new(endpoint.map(ToOwned::to_owned));
197 client.fetch(request)
198}
199
200pub fn client_download_exists(
201 endpoint: Option<&str>,
202 params: DownloadParams,
203) -> Result<FetchState, String> {
204 let request = build_download_request(params);
205 let client = zccache_download_client::DownloadClient::new(endpoint.map(ToOwned::to_owned));
206 client.exists(&request)
207}
208
209fn infer_download_file_name(source: &DownloadSource, archive_format: ArchiveFormat) -> String {
210 let base = infer_source_file_name(source);
211 let hash = blake3::hash(download_source_key(source).as_bytes())
212 .to_hex()
213 .to_string();
214 let suffix = archive_suffix(archive_format);
215
216 if base.contains('.') || suffix.is_empty() {
217 format!("{hash}-{base}")
218 } else {
219 format!("{hash}-{base}{suffix}")
220 }
221}
222
223fn infer_source_file_name(source: &DownloadSource) -> String {
224 match source {
225 DownloadSource::Url(url) => {
226 infer_url_file_name(url).unwrap_or_else(|| "download".to_string())
227 }
228 DownloadSource::MultipartUrls(urls) => infer_multipart_file_name(urls),
229 }
230}
231
232fn infer_url_file_name(url: &str) -> Option<String> {
233 url.split(['?', '#'])
234 .next()
235 .and_then(|value| value.rsplit('/').next())
236 .filter(|value| !value.is_empty())
237 .map(sanitize_download_file_name)
238 .filter(|value| !value.is_empty())
239}
240
241fn infer_multipart_file_name(urls: &[String]) -> String {
242 let base = urls
243 .first()
244 .and_then(|url| infer_url_file_name(url))
245 .map(|name| strip_part_suffix(&name).to_string())
246 .filter(|name| !name.is_empty())
247 .unwrap_or_else(|| "multipart-download".to_string());
248 if base.contains('.') {
249 base
250 } else {
251 "multipart-download".to_string()
252 }
253}
254
255fn strip_part_suffix(value: &str) -> &str {
256 if let Some((base, suffix)) = value.rsplit_once(".part-") {
257 if !base.is_empty() && !suffix.is_empty() {
258 return base;
259 }
260 }
261 if let Some((base, suffix)) = value.rsplit_once(".part_") {
262 if !base.is_empty() && !suffix.is_empty() {
263 return base;
264 }
265 }
266 if let Some(index) = value.rfind(".part") {
267 let suffix = &value[index + ".part".len()..];
268 if !suffix.is_empty()
269 && suffix
270 .chars()
271 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
272 {
273 return &value[..index];
274 }
275 }
276 value
277}
278
279fn download_source_key(source: &DownloadSource) -> String {
280 match source {
281 DownloadSource::Url(url) => url.clone(),
282 DownloadSource::MultipartUrls(urls) => urls.join("\n"),
283 }
284}
285
286fn sanitize_download_file_name(value: &str) -> String {
287 value
288 .chars()
289 .map(|ch| match ch {
290 '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
291 c if c.is_control() => '_',
292 c => c,
293 })
294 .collect()
295}
296
297fn archive_suffix(format: ArchiveFormat) -> &'static str {
298 match format {
299 ArchiveFormat::Auto | ArchiveFormat::None => "",
300 ArchiveFormat::Zst => ".zst",
301 ArchiveFormat::Zip => ".zip",
302 ArchiveFormat::Xz => ".xz",
303 ArchiveFormat::TarGz => ".tar.gz",
304 ArchiveFormat::TarXz => ".tar.xz",
305 ArchiveFormat::TarZst => ".tar.zst",
306 ArchiveFormat::SevenZip => ".7z",
307 }
308}
309
310fn run_async<T>(future: impl std::future::Future<Output = Result<T, String>>) -> Result<T, String> {
311 tokio::runtime::Builder::new_current_thread()
312 .enable_all()
313 .build()
314 .map_err(|e| format!("failed to create tokio runtime: {e}"))?
315 .block_on(future)
316}
317
318#[derive(Debug)]
319enum VersionCheck {
320 Ok,
321 Unreachable,
322 DaemonOlder { daemon_ver: String },
323 DaemonNewer,
324 CommError,
325}
326
327#[cfg(unix)]
328async fn connect_client(
329 endpoint: &str,
330) -> Result<zccache_ipc::IpcConnection, zccache_ipc::IpcError> {
331 zccache_ipc::connect(endpoint).await
332}
333
334#[cfg(windows)]
335async fn connect_client(
336 endpoint: &str,
337) -> Result<zccache_ipc::IpcClientConnection, zccache_ipc::IpcError> {
338 zccache_ipc::connect(endpoint).await
339}
340
341async fn check_daemon_version(endpoint: &str) -> VersionCheck {
342 let mut conn = match connect_client(endpoint).await {
343 Ok(c) => c,
344 Err(_) => return VersionCheck::Unreachable,
345 };
346 if conn.send(&zccache_protocol::Request::Status).await.is_err() {
347 return VersionCheck::CommError;
348 }
349 match conn.recv::<zccache_protocol::Response>().await {
350 Ok(Some(zccache_protocol::Response::Status(s))) => {
351 if s.version == zccache_core::VERSION {
352 return VersionCheck::Ok;
353 }
354 let client_ver = zccache_core::version::current();
355 match zccache_core::version::Version::parse(&s.version) {
356 Some(daemon_ver) => match daemon_ver.cmp(&client_ver) {
357 std::cmp::Ordering::Equal => VersionCheck::Ok,
358 std::cmp::Ordering::Greater => VersionCheck::DaemonNewer,
359 std::cmp::Ordering::Less => VersionCheck::DaemonOlder {
360 daemon_ver: s.version,
361 },
362 },
363 None => VersionCheck::DaemonOlder {
364 daemon_ver: s.version,
365 },
366 }
367 }
368 _ => VersionCheck::CommError,
369 }
370}
371
372async fn spawn_and_wait(endpoint: &str) -> Result<(), String> {
373 let daemon_bin = find_daemon_binary().ok_or("cannot find zccache-daemon binary")?;
374 spawn_daemon(&daemon_bin, endpoint)?;
375
376 for _ in 0..100 {
377 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
378 if connect_client(endpoint).await.is_ok() {
379 return Ok(());
380 }
381 }
382 Err("daemon started but not accepting connections after 10s".to_string())
383}
384
385async fn stop_stale_daemon(endpoint: &str) {
387 if let Ok(mut conn) = connect_client(endpoint).await {
388 let _ = conn.send(&zccache_protocol::Request::Shutdown).await;
389 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
390 }
391
392 if let Some(pid) = zccache_ipc::check_running_daemon() {
393 if zccache_ipc::force_kill_process(pid).is_ok() {
394 for _ in 0..50 {
395 if !zccache_ipc::is_process_alive(pid) {
396 break;
397 }
398 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
399 }
400 }
401 zccache_ipc::remove_lock_file();
402 }
403
404 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
405}
406
407async fn ensure_daemon(endpoint: &str) -> Result<(), String> {
408 match check_daemon_version(endpoint).await {
409 VersionCheck::Ok | VersionCheck::DaemonNewer => return Ok(()),
410 VersionCheck::DaemonOlder { daemon_ver } => {
411 tracing::info!(
412 daemon_ver,
413 client_ver = zccache_core::VERSION,
414 "daemon is older than client, auto-recovering"
415 );
416 stop_stale_daemon(endpoint).await;
417 return spawn_and_wait(endpoint).await;
418 }
419 VersionCheck::CommError => {
420 tracing::info!("cannot communicate with daemon, auto-recovering");
421 stop_stale_daemon(endpoint).await;
422 return spawn_and_wait(endpoint).await;
423 }
424 VersionCheck::Unreachable => {}
425 }
426
427 if let Some(pid) = zccache_ipc::check_running_daemon() {
428 let mut backoff = std::time::Duration::from_millis(100);
429 for _ in 0..20 {
430 tokio::time::sleep(backoff).await;
431 backoff = (backoff * 2).min(std::time::Duration::from_millis(500));
432 match check_daemon_version(endpoint).await {
433 VersionCheck::Ok | VersionCheck::DaemonNewer => return Ok(()),
434 VersionCheck::DaemonOlder { daemon_ver } => {
435 tracing::info!(
436 daemon_ver,
437 client_ver = zccache_core::VERSION,
438 "daemon is older than client during startup, auto-recovering"
439 );
440 stop_stale_daemon(endpoint).await;
441 return spawn_and_wait(endpoint).await;
442 }
443 VersionCheck::CommError => {
444 stop_stale_daemon(endpoint).await;
445 return spawn_and_wait(endpoint).await;
446 }
447 VersionCheck::Unreachable => continue,
448 }
449 }
450 return Err(format!(
451 "daemon process {pid} exists but not accepting connections after retrying"
452 ));
453 }
454
455 spawn_and_wait(endpoint).await
456}
457
458fn find_daemon_binary() -> Option<NormalizedPath> {
459 let name = if cfg!(windows) {
460 "zccache-daemon.exe"
461 } else {
462 "zccache-daemon"
463 };
464
465 if let Ok(exe) = std::env::current_exe() {
466 if let Some(dir) = exe.parent() {
467 let candidate = dir.join(name);
468 if candidate.exists() {
469 return Some(candidate.into());
470 }
471 }
472 }
473
474 which_on_path(name)
475}
476
477fn which_on_path(name: &str) -> Option<NormalizedPath> {
478 let path_var = std::env::var_os("PATH")?;
479 for dir in std::env::split_paths(&path_var) {
480 let candidate = dir.join(name);
481 if candidate.is_file() {
482 return Some(candidate.into());
483 }
484 #[cfg(windows)]
485 if Path::new(name).extension().is_none() {
486 let with_exe = dir.join(format!("{name}.exe"));
487 if with_exe.is_file() {
488 return Some(with_exe.into());
489 }
490 }
491 }
492 None
493}
494
495fn spawn_daemon(bin: &Path, endpoint: &str) -> Result<(), String> {
496 let mut cmd = std::process::Command::new(bin);
497 cmd.args(["--foreground", "--endpoint", endpoint]);
498 cmd.stdin(std::process::Stdio::null());
499 cmd.stdout(std::process::Stdio::null());
500 cmd.stderr(std::process::Stdio::null());
501
502 #[cfg(windows)]
503 {
504 use std::os::windows::process::CommandExt;
505 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
506 cmd.creation_flags(CREATE_NO_WINDOW);
507 disable_handle_inheritance();
508 }
509
510 cmd.spawn()
511 .map_err(|e| format!("failed to spawn daemon: {e}"))?;
512
513 #[cfg(windows)]
514 restore_handle_inheritance();
515
516 Ok(())
517}
518
519#[cfg(windows)]
520fn disable_handle_inheritance() {
521 use std::os::windows::io::AsRawHandle;
522
523 extern "system" {
524 fn SetHandleInformation(handle: *mut std::ffi::c_void, mask: u32, flags: u32) -> i32;
525 }
526 const HANDLE_FLAG_INHERIT: u32 = 1;
527
528 unsafe {
529 let stdout = std::io::stdout().as_raw_handle();
530 let stderr = std::io::stderr().as_raw_handle();
531 let _ = SetHandleInformation(stdout.cast(), HANDLE_FLAG_INHERIT, 0);
532 let _ = SetHandleInformation(stderr.cast(), HANDLE_FLAG_INHERIT, 0);
533 }
534}
535
536#[cfg(windows)]
537fn restore_handle_inheritance() {
538 use std::os::windows::io::AsRawHandle;
539
540 extern "system" {
541 fn SetHandleInformation(handle: *mut std::ffi::c_void, mask: u32, flags: u32) -> i32;
542 }
543 const HANDLE_FLAG_INHERIT: u32 = 1;
544
545 unsafe {
546 let stdout = std::io::stdout().as_raw_handle();
547 let stderr = std::io::stderr().as_raw_handle();
548 let _ = SetHandleInformation(stdout.cast(), HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT);
549 let _ = SetHandleInformation(stderr.cast(), HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT);
550 }
551}
552
553#[derive(Debug, Clone)]
554pub struct SessionStartResponse {
555 pub session_id: String,
556 pub journal_path: Option<String>,
557}
558
559pub fn client_start(endpoint: Option<&str>) -> Result<(), String> {
560 let endpoint = resolve_endpoint(endpoint);
561 run_async(async move { ensure_daemon(&endpoint).await })
562}
563
564pub fn client_stop(endpoint: Option<&str>) -> Result<bool, String> {
565 let endpoint = resolve_endpoint(endpoint);
566 run_async(async move {
567 let mut conn = match connect_client(&endpoint).await {
568 Ok(c) => c,
569 Err(_) => return Ok(false),
570 };
571 conn.send(&zccache_protocol::Request::Shutdown)
572 .await
573 .map_err(|e| format!("failed to send to daemon: {e}"))?;
574 match conn.recv::<zccache_protocol::Response>().await {
575 Ok(Some(zccache_protocol::Response::ShuttingDown)) => Ok(true),
576 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
577 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
578 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
579 Err(e) => Err(format!("broken connection to daemon: {e}")),
580 }
581 })
582}
583
584pub fn client_status(endpoint: Option<&str>) -> Result<zccache_protocol::DaemonStatus, String> {
585 let endpoint = resolve_endpoint(endpoint);
586 run_async(async move {
587 let mut conn = connect_client(&endpoint)
588 .await
589 .map_err(|e| format!("daemon not running at {endpoint}: {e}"))?;
590 conn.send(&zccache_protocol::Request::Status)
591 .await
592 .map_err(|e| format!("failed to send to daemon: {e}"))?;
593 match conn.recv::<zccache_protocol::Response>().await {
594 Ok(Some(zccache_protocol::Response::Status(status))) => Ok(status),
595 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
596 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
597 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
598 Err(e) => Err(format!("broken connection to daemon: {e}")),
599 }
600 })
601}
602
603pub fn client_session_start(
604 endpoint: Option<&str>,
605 cwd: &Path,
606 log_file: Option<&Path>,
607 track_stats: bool,
608 journal_path: Option<&Path>,
609) -> Result<SessionStartResponse, String> {
610 let endpoint = resolve_endpoint(endpoint);
611 let cwd = cwd.to_path_buf();
612 let log_file = log_file.map(NormalizedPath::from);
613 let journal_path = journal_path.map(NormalizedPath::from);
614
615 run_async(async move {
616 ensure_daemon(&endpoint).await?;
617 let mut conn = connect_client(&endpoint)
618 .await
619 .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
620 conn.send(&zccache_protocol::Request::SessionStart {
621 client_pid: std::process::id(),
622 working_dir: cwd.into(),
623 log_file,
624 track_stats,
625 journal_path,
626 })
627 .await
628 .map_err(|e| format!("failed to send to daemon: {e}"))?;
629
630 match conn.recv::<zccache_protocol::Response>().await {
631 Ok(Some(zccache_protocol::Response::SessionStarted {
632 session_id,
633 journal_path,
634 })) => Ok(SessionStartResponse {
635 session_id,
636 journal_path: journal_path.map(|p| p.display().to_string()),
637 }),
638 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
639 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
640 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
641 Err(e) => Err(format!("broken connection to daemon: {e}")),
642 }
643 })
644}
645
646pub fn client_session_end(
647 endpoint: Option<&str>,
648 session_id: &str,
649) -> Result<Option<zccache_protocol::SessionStats>, String> {
650 let endpoint = resolve_endpoint(endpoint);
651 let session_id = session_id.to_string();
652 run_async(async move {
653 let mut conn = connect_client(&endpoint)
654 .await
655 .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
656 conn.send(&zccache_protocol::Request::SessionEnd {
657 session_id: session_id.clone(),
658 })
659 .await
660 .map_err(|e| format!("failed to send to daemon: {e}"))?;
661
662 match conn.recv::<zccache_protocol::Response>().await {
663 Ok(Some(zccache_protocol::Response::SessionEnded { stats })) => Ok(stats),
664 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
665 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
666 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
667 Err(e) => Err(format!("broken connection to daemon: {e}")),
668 }
669 })
670}
671
672pub fn client_session_stats(
673 endpoint: Option<&str>,
674 session_id: &str,
675) -> Result<Option<zccache_protocol::SessionStats>, String> {
676 let endpoint = resolve_endpoint(endpoint);
677 let session_id = session_id.to_string();
678 run_async(async move {
679 let mut conn = connect_client(&endpoint)
680 .await
681 .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
682 conn.send(&zccache_protocol::Request::SessionStats {
683 session_id: session_id.clone(),
684 })
685 .await
686 .map_err(|e| format!("failed to send to daemon: {e}"))?;
687
688 match conn.recv::<zccache_protocol::Response>().await {
689 Ok(Some(zccache_protocol::Response::SessionStatsResult { stats })) => Ok(stats),
690 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
691 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
692 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
693 Err(e) => Err(format!("broken connection to daemon: {e}")),
694 }
695 })
696}
697
698#[derive(Debug, Clone)]
699pub struct FingerprintCheckResponse {
700 pub decision: String,
701 pub reason: Option<String>,
702 pub changed_files: Vec<String>,
703}
704
705pub fn fingerprint_check(
706 endpoint: Option<&str>,
707 cache_file: &Path,
708 cache_type: &str,
709 root: &Path,
710 extensions: &[String],
711 include_globs: &[String],
712 exclude: &[String],
713) -> Result<FingerprintCheckResponse, String> {
714 let endpoint = resolve_endpoint(endpoint);
715 let cache_file = cache_file.to_path_buf();
716 let cache_type = cache_type.to_string();
717 let root = root.to_path_buf();
718 let extensions = extensions.to_vec();
719 let include_globs = include_globs.to_vec();
720 let exclude = exclude.to_vec();
721
722 run_async(async move {
723 ensure_daemon(&endpoint).await?;
724 let mut conn = connect_client(&endpoint)
725 .await
726 .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
727
728 conn.send(&zccache_protocol::Request::FingerprintCheck {
729 cache_file: cache_file.into(),
730 cache_type,
731 root: root.into(),
732 extensions,
733 include_globs,
734 exclude,
735 })
736 .await
737 .map_err(|e| format!("failed to send to daemon: {e}"))?;
738
739 match conn.recv::<zccache_protocol::Response>().await {
740 Ok(Some(zccache_protocol::Response::FingerprintCheckResult {
741 decision,
742 reason,
743 changed_files,
744 })) => Ok(FingerprintCheckResponse {
745 decision,
746 reason,
747 changed_files,
748 }),
749 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
750 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
751 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
752 Err(e) => Err(format!("broken connection to daemon: {e}")),
753 }
754 })
755}
756
757pub fn fingerprint_mark_success(endpoint: Option<&str>, cache_file: &Path) -> Result<(), String> {
758 fingerprint_mark(endpoint, cache_file, true)
759}
760
761pub fn fingerprint_mark_failure(endpoint: Option<&str>, cache_file: &Path) -> Result<(), String> {
762 fingerprint_mark(endpoint, cache_file, false)
763}
764
765fn fingerprint_mark(
766 endpoint: Option<&str>,
767 cache_file: &Path,
768 success: bool,
769) -> Result<(), String> {
770 let endpoint = resolve_endpoint(endpoint);
771 let cache_file = cache_file.to_path_buf();
772 run_async(async move {
773 ensure_daemon(&endpoint).await?;
774 let mut conn = connect_client(&endpoint)
775 .await
776 .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
777 let request = if success {
778 zccache_protocol::Request::FingerprintMarkSuccess {
779 cache_file: cache_file.into(),
780 }
781 } else {
782 zccache_protocol::Request::FingerprintMarkFailure {
783 cache_file: cache_file.into(),
784 }
785 };
786 conn.send(&request)
787 .await
788 .map_err(|e| format!("failed to send to daemon: {e}"))?;
789 match conn.recv::<zccache_protocol::Response>().await {
790 Ok(Some(zccache_protocol::Response::FingerprintAck)) => Ok(()),
791 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
792 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
793 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
794 Err(e) => Err(format!("broken connection to daemon: {e}")),
795 }
796 })
797}
798
799pub fn fingerprint_invalidate(endpoint: Option<&str>, cache_file: &Path) -> Result<(), String> {
800 let endpoint = resolve_endpoint(endpoint);
801 let cache_file = cache_file.to_path_buf();
802 run_async(async move {
803 ensure_daemon(&endpoint).await?;
804 let mut conn = connect_client(&endpoint)
805 .await
806 .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
807 conn.send(&zccache_protocol::Request::FingerprintInvalidate {
808 cache_file: cache_file.into(),
809 })
810 .await
811 .map_err(|e| format!("failed to send to daemon: {e}"))?;
812 match conn.recv::<zccache_protocol::Response>().await {
813 Ok(Some(zccache_protocol::Response::FingerprintAck)) => Ok(()),
814 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
815 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
816 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
817 Err(e) => Err(format!("broken connection to daemon: {e}")),
818 }
819 })
820}
821
822#[cfg(test)]
823mod tests {
824 use super::*;
825 use std::ffi::OsString;
826 use std::sync::{Mutex, MutexGuard};
827
828 static ENV_LOCK: Mutex<()> = Mutex::new(());
829
830 struct EnvGuard {
831 _lock: MutexGuard<'static, ()>,
832 previous: Option<OsString>,
833 }
834
835 impl EnvGuard {
836 fn set_cache_dir(value: &std::path::Path) -> Self {
837 let lock = ENV_LOCK.lock().unwrap();
838 let previous = std::env::var_os(zccache_core::config::CACHE_DIR_ENV);
839 std::env::set_var(zccache_core::config::CACHE_DIR_ENV, value);
840 Self {
841 _lock: lock,
842 previous,
843 }
844 }
845 }
846
847 impl Drop for EnvGuard {
848 fn drop(&mut self) {
849 match &self.previous {
850 Some(value) => std::env::set_var(zccache_core::config::CACHE_DIR_ENV, value),
851 None => std::env::remove_var(zccache_core::config::CACHE_DIR_ENV),
852 }
853 }
854 }
855
856 fn fake_status() -> zccache_protocol::DaemonStatus {
857 zccache_protocol::DaemonStatus {
858 version: zccache_core::VERSION.to_string(),
859 artifact_count: 0,
860 cache_size_bytes: 0,
861 metadata_entries: 0,
862 uptime_secs: 0,
863 cache_hits: 0,
864 cache_misses: 0,
865 total_compilations: 0,
866 non_cacheable: 0,
867 compile_errors: 0,
868 time_saved_ms: 0,
869 total_links: 0,
870 link_hits: 0,
871 link_misses: 0,
872 link_non_cacheable: 0,
873 dep_graph_contexts: 0,
874 dep_graph_files: 0,
875 sessions_total: 0,
876 sessions_active: 0,
877 cache_dir: zccache_core::config::default_cache_dir(),
878 dep_graph_version: 0,
879 dep_graph_disk_size: 0,
880 }
881 }
882
883 #[test]
884 fn infer_download_path_keeps_url_filename() {
885 let path = infer_download_archive_path(
886 &DownloadSource::Url("https://example.com/releases/toolchain.tar.gz?download=1".into()),
887 ArchiveFormat::Auto,
888 );
889 let file_name = path.file_name().unwrap().to_string_lossy();
890 assert!(file_name.ends_with("-toolchain.tar.gz"));
891 }
892
893 #[test]
894 fn infer_download_path_uses_archive_format_suffix_when_needed() {
895 let path = infer_download_archive_path(
896 &DownloadSource::Url("https://example.com/download".into()),
897 ArchiveFormat::Zip,
898 );
899 let file_name = path.file_name().unwrap().to_string_lossy();
900 assert!(file_name.ends_with(".zip"));
901 }
902
903 #[test]
904 fn build_download_request_derives_archive_path_when_missing() {
905 let request = build_download_request(DownloadParams::new("https://example.com/file.zip"));
906 let file_name = request
907 .destination_path
908 .file_name()
909 .unwrap()
910 .to_string_lossy();
911 assert!(file_name.ends_with("-file.zip"));
912 }
913
914 #[test]
915 fn infer_download_path_strips_multipart_suffix_from_first_part() {
916 let path = infer_download_archive_path(
917 &DownloadSource::MultipartUrls(vec![
918 "https://example.com/toolchain.tar.zst.part-aa".into(),
919 "https://example.com/toolchain.tar.zst.part-ab".into(),
920 ]),
921 ArchiveFormat::Auto,
922 );
923 let file_name = path.file_name().unwrap().to_string_lossy();
924 assert!(file_name.ends_with("-toolchain.tar.zst"));
925 }
926
927 #[tokio::test(flavor = "current_thread")]
928 async fn client_start_waits_for_delayed_listener() {
929 let endpoint = zccache_ipc::unique_test_endpoint();
930 let cache_root = tempfile::tempdir().unwrap();
931 let _env = EnvGuard::set_cache_dir(cache_root.path());
932 let lock_path = zccache_ipc::lock_file_path();
933
934 std::fs::write(&lock_path, std::process::id().to_string()).unwrap();
935
936 let ep = endpoint.clone();
937 let cache_dir = cache_root.path().to_path_buf();
938 let server = tokio::spawn(async move {
939 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
940
941 let mut listener = zccache_ipc::IpcListener::bind(&ep).unwrap();
942 let mut conn = listener.accept().await.unwrap();
943
944 let req: Option<zccache_protocol::Request> = conn.recv().await.unwrap();
945 assert_eq!(req, Some(zccache_protocol::Request::Status));
946
947 conn.send(&zccache_protocol::Response::Status(
948 zccache_protocol::DaemonStatus {
949 cache_dir: cache_dir.into(),
950 ..fake_status()
951 },
952 ))
953 .await
954 .unwrap();
955 });
956
957 let result = tokio::task::spawn_blocking(move || client_start(Some(&endpoint)))
958 .await
959 .unwrap();
960 let _ = std::fs::remove_file(&lock_path);
961 server.await.unwrap();
962
963 assert!(
964 result.is_ok(),
965 "expected delayed daemon to be accepted: {result:?}"
966 );
967 }
968}