1mod events;
13
14use eyre::{Context, Result};
15use mbx_cache_core::{
16 ActionPrediction, CacheDigest, CacheDirectory, LocalCas, RemoteActionResult, RustcMetadata,
17 TaskActionManifest, is_task_identity, task_manifest_actions,
18};
19use serde::{Deserialize, Serialize};
20use std::collections::{BTreeMap, BTreeSet, HashSet};
21use std::path::{Component, Path, PathBuf};
22use std::time::{Duration, SystemTime, UNIX_EPOCH};
23
24const CAS_DIR: &str = "cas/v1";
25const ACTION_RESULTS_DIR: &str = "action-results/v1";
26const CHECKOUTS_DIR: &str = "checkouts/v1";
27const SWEEP_STAMP: &str = "gc/v1/last-sweep";
28const SWEEP_LOCK: &str = "gc/v1/sweep.lock";
29const CHECKOUT_RECORD_VERSION: u8 = 1;
30const BUILD_RECEIPTS_DIR: &str = "build-receipts/v1";
31const BUILD_RECEIPT_VERSION: u8 = 1;
32const EXPORT_MANIFEST: &str = "mbx-cache-export-v1.json";
33const EXPORT_VERSION: u8 = 2;
34const LEGACY_EXPORT_VERSION: u8 = 1;
35
36const SESSION_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
37const MAX_SESSIONS: usize = 256;
38
39const CHECKOUT_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60);
49
50#[derive(Debug, Default, PartialEq, Eq)]
51pub struct StoreStats {
52 pub objects: u64,
53 pub object_bytes: u64,
54 pub action_results: u64,
55 pub action_result_bytes: u64,
56 pub live_checkouts: u64,
57 pub stale_checkouts: u64,
62}
63
64impl StoreStats {
65 pub fn total_bytes(&self) -> u64 {
66 self.object_bytes.saturating_add(self.action_result_bytes)
67 }
68}
69
70#[derive(Debug, Default, PartialEq, Eq)]
71pub struct GcOutcome {
72 pub removed_objects: u64,
73 pub removed_action_results: u64,
74 pub removed_checkout_records: u64,
75 pub removed_session_streams: u64,
78 pub removed_bytes: u64,
79 pub remaining_bytes: u64,
80}
81
82#[derive(Debug, PartialEq, Eq)]
83pub struct ProjectUsage {
84 pub workspace_root: PathBuf,
85 pub identities: u64,
86 pub action_bytes: u64,
87 pub target_bytes: u64,
88 pub live: bool,
89}
90
91#[derive(Debug, PartialEq, Eq)]
92pub struct LargestEntry {
93 pub kind: &'static str,
94 pub path: PathBuf,
95 pub bytes: u64,
96}
97
98#[derive(Debug, Default, PartialEq, Eq)]
99pub struct VerifyOutcome {
100 pub checked_objects: u64,
101 pub checked_action_results: u64,
102 pub problems: Vec<PathBuf>,
103}
104
105#[derive(Debug, Default, PartialEq, Eq)]
106pub struct RemoveProjectOutcome {
107 pub removed_checkout_records: u64,
108}
109
110#[derive(Debug, Default, PartialEq, Eq)]
111pub struct TransferOutcome {
112 pub actions: u64,
113 pub objects: u64,
114 pub bytes: u64,
115}
116
117#[derive(Debug)]
119#[non_exhaustive]
120pub struct ImportOutcome {
121 pub transfer: TransferOutcome,
122 pub attachments: BTreeMap<String, CacheDigest>,
123}
124
125#[derive(Debug, Default)]
127pub struct ExportAdditions {
128 pub attachments: BTreeMap<String, CacheDigest>,
130 pub objects: BTreeSet<CacheDigest>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
136pub struct WorkspaceTarget {
137 pub workspace_root: PathBuf,
138 pub target_dir: PathBuf,
139}
140
141#[derive(Debug, Serialize, Deserialize)]
142#[serde(deny_unknown_fields)]
143struct ExportManifest {
144 version: u8,
145 tasks: Vec<TaskActionManifest>,
146 actions: Vec<CacheDigest>,
147 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
148 attachments: BTreeMap<String, CacheDigest>,
149 #[serde(default, skip_serializing_if = "Vec::is_empty")]
150 objects: Vec<CacheDigest>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
155#[serde(deny_unknown_fields)]
156struct BuildReceipt {
157 version: u8,
158 workspace_root: PathBuf,
159 identity: String,
160 completed_nanos: u64,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 group: Option<String>,
163 predictions: Vec<ActionPrediction>,
164}
165
166pub fn record_build_receipt(
168 store: &Path,
169 run: &str,
170 identity: &str,
171 workspace_root: &Path,
172 group: Option<&str>,
173 predictions: Vec<ActionPrediction>,
174) -> Result<()> {
175 if !is_task_identity(run) || !is_task_identity(identity) {
176 eyre::bail!("invalid build receipt identity");
177 }
178 if !(TaskActionManifest {
179 version: 1,
180 task: identity.to_owned(),
181 predictions: predictions.clone(),
182 })
183 .validate()
184 {
185 eyre::bail!("invalid build receipt prediction");
186 }
187 if let Some(group) = group {
188 validate_export_group(group)?;
189 }
190 let key = workspace_key(workspace_root);
191 let lock_path = store
192 .join(BUILD_RECEIPTS_DIR)
193 .join("locks")
194 .join(format!("{key}.lock"));
195 std::fs::create_dir_all(lock_path.parent().expect("receipt lock has a parent"))?;
196 let mut lock = fslock::LockFile::open(&lock_path)?;
197 lock.lock()?;
198 let completed_nanos = SystemTime::now()
199 .duration_since(UNIX_EPOCH)
200 .map(|elapsed| elapsed.as_nanos().try_into().unwrap_or(u64::MAX))
201 .unwrap_or_default();
202 let receipt = BuildReceipt {
203 version: BUILD_RECEIPT_VERSION,
204 workspace_root: workspace_root.to_path_buf(),
205 identity: identity.to_owned(),
206 completed_nanos,
207 group: group.map(str::to_owned),
208 predictions,
209 };
210 let bytes = serde_json::to_vec(&receipt)?;
211 write_atomic(&latest_receipt_path(store, workspace_root), &bytes)?;
212 if let Some(group) = group {
213 write_atomic(&group_receipt_path(store, group, run), &bytes)?;
214 }
215 Ok(())
216}
217
218fn workspace_key(workspace_root: &Path) -> String {
219 CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash
220}
221
222fn latest_receipt_path(store: &Path, workspace_root: &Path) -> PathBuf {
223 store
224 .join(BUILD_RECEIPTS_DIR)
225 .join("checkouts")
226 .join(format!("{}.json", workspace_key(workspace_root)))
227}
228
229fn group_receipt_path(store: &Path, group: &str, run: &str) -> PathBuf {
230 store
231 .join(BUILD_RECEIPTS_DIR)
232 .join("groups")
233 .join(group_key(group))
234 .join(format!("{run}.json"))
235}
236
237fn group_key(group: &str) -> String {
238 CacheDigest::blake3(group.as_bytes()).hash
239}
240
241fn read_build_receipt(path: &Path) -> Option<BuildReceipt> {
242 let bytes = std::fs::read(path).ok()?;
243 let receipt = serde_json::from_slice::<BuildReceipt>(&bytes).ok()?;
244 let valid = TaskActionManifest {
245 version: 1,
246 task: receipt.identity.clone(),
247 predictions: receipt.predictions.clone(),
248 }
249 .validate();
250 (receipt.version == BUILD_RECEIPT_VERSION && valid).then_some(receipt)
251}
252
253fn validate_export_group(group: &str) -> Result<()> {
254 if group.is_empty() || group.len() > 256 || group.chars().any(char::is_control) {
255 eyre::bail!("invalid build export group");
256 }
257 Ok(())
258}
259
260pub fn export_checkout(
262 store: &Path,
263 workspace_root: &Path,
264 archive: &Path,
265) -> Result<TransferOutcome> {
266 let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
267 .filter(|receipt| receipt.workspace_root == workspace_root)
268 .ok_or_else(|| {
269 eyre::eyre!(
270 "no completed mbx build is recorded for {}",
271 workspace_root.display()
272 )
273 })?;
274 export_receipts(store, vec![receipt], archive, ExportAdditions::default())
275}
276
277pub fn checkout_workspace_target(
279 store: &Path,
280 workspace_root: &Path,
281) -> Result<Option<WorkspaceTarget>> {
282 let Some(receipt) = read_build_receipt(&latest_receipt_path(store, workspace_root))
283 .filter(|receipt| receipt.workspace_root == workspace_root)
284 else {
285 return Ok(None);
286 };
287 Ok(workspace_target_for_receipt(store, &receipt))
288}
289
290pub fn group_workspace_targets(store: &Path, group: &str) -> Result<Vec<WorkspaceTarget>> {
292 validate_export_group(group)?;
293 let root = store
294 .join(BUILD_RECEIPTS_DIR)
295 .join("groups")
296 .join(group_key(group));
297 let targets = walk_files(&root)?
298 .into_iter()
299 .filter_map(|entry| read_build_receipt(&entry.path))
300 .filter(|receipt| receipt.group.as_deref() == Some(group))
301 .filter_map(|receipt| workspace_target_for_receipt(store, &receipt))
302 .collect::<BTreeSet<_>>();
303 Ok(targets.into_iter().collect())
304}
305
306fn workspace_target_for_receipt(store: &Path, receipt: &BuildReceipt) -> Option<WorkspaceTarget> {
307 let record = read_checkout_record(&checkout_record_path(
308 store,
309 &receipt.identity,
310 &receipt.workspace_root,
311 ))?;
312 (record.workspace_root == receipt.workspace_root && record.target_dir != record.workspace_root)
313 .then_some(WorkspaceTarget {
314 workspace_root: record.workspace_root,
315 target_dir: record.target_dir,
316 })
317}
318
319pub fn export_checkout_with(
321 store: &Path,
322 workspace_root: &Path,
323 archive: &Path,
324 additions: ExportAdditions,
325) -> Result<TransferOutcome> {
326 let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
327 .filter(|receipt| receipt.workspace_root == workspace_root)
328 .ok_or_else(|| {
329 eyre::eyre!(
330 "no completed mbx build is recorded for {}",
331 workspace_root.display()
332 )
333 })?;
334 export_receipts(store, vec![receipt], archive, additions)
335}
336
337pub fn export_group(store: &Path, group: &str, archive: &Path) -> Result<TransferOutcome> {
339 export_group_with(store, group, archive, ExportAdditions::default())
340}
341
342pub fn export_group_with(
344 store: &Path,
345 group: &str,
346 archive: &Path,
347 additions: ExportAdditions,
348) -> Result<TransferOutcome> {
349 validate_export_group(group)?;
350 let root = store
351 .join(BUILD_RECEIPTS_DIR)
352 .join("groups")
353 .join(group_key(group));
354 let receipts = walk_files(&root)?
355 .into_iter()
356 .filter_map(|entry| read_build_receipt(&entry.path).map(|receipt| (entry.path, receipt)))
357 .filter(|(_, receipt)| receipt.group.as_deref() == Some(group))
358 .collect::<Vec<_>>();
359 if receipts.is_empty() {
360 eyre::bail!("no completed mbx builds are recorded for export group {group:?}");
361 }
362 let outcome = export_receipts(
363 store,
364 receipts
365 .iter()
366 .map(|(_, receipt)| receipt.clone())
367 .collect(),
368 archive,
369 additions,
370 )?;
371 for (path, _) in receipts {
376 std::fs::remove_file(path)?;
377 }
378 let _ = std::fs::remove_dir(root);
379 Ok(outcome)
380}
381
382fn export_receipts(
383 store: &Path,
384 mut receipts: Vec<BuildReceipt>,
385 archive: &Path,
386 additions: ExportAdditions,
387) -> Result<TransferOutcome> {
388 receipts.sort_by_key(|receipt| receipt.completed_nanos);
389 let mut actions = BTreeSet::new();
390 let mut tasks = BTreeMap::new();
391 for receipt in receipts {
392 actions.extend(
393 receipt
394 .predictions
395 .iter()
396 .map(|prediction| prediction.action.clone()),
397 );
398 tasks.insert(
402 receipt.identity.clone(),
403 TaskActionManifest {
404 version: 1,
405 task: receipt.identity,
406 predictions: receipt.predictions,
407 },
408 );
409 }
410 let tasks = tasks.into_values().collect::<Vec<_>>();
411 validate_export_additions(&additions)?;
412 let (mut objects, result_paths) = strict_closure(store, &actions)?;
413 let cas = LocalCas::new(store);
414 for digest in &additions.objects {
415 require_object(&cas, &mut objects, digest)?;
416 }
417 let parent = archive
418 .parent()
419 .filter(|parent| !parent.as_os_str().is_empty())
420 .unwrap_or_else(|| Path::new("."));
421 std::fs::create_dir_all(parent)?;
422 let temporary = tempfile::Builder::new()
423 .prefix(".mbx-export-")
424 .tempfile_in(parent)?;
425 let mut builder = tar::Builder::new(temporary.reopen()?);
426 append_bytes(
427 &mut builder,
428 Path::new(EXPORT_MANIFEST),
429 &serde_json::to_vec(&ExportManifest {
430 version: if additions.attachments.is_empty() && additions.objects.is_empty() {
431 LEGACY_EXPORT_VERSION
432 } else {
433 EXPORT_VERSION
434 },
435 tasks,
436 actions: actions.iter().cloned().collect(),
437 attachments: additions.attachments.clone(),
438 objects: additions.objects.iter().cloned().collect(),
439 })?,
440 )?;
441 for path in objects.iter().chain(result_paths.iter()) {
442 append_file(&mut builder, store, path)?;
443 }
444 builder.finish()?;
445 drop(builder);
446 temporary
447 .persist(archive)
448 .map_err(|error| error.error)
449 .wrap_err_with(|| format!("failed to publish {}", archive.display()))?;
450 let bytes = std::fs::metadata(archive)?.len();
451 Ok(TransferOutcome {
452 actions: actions.len() as u64,
453 objects: objects.len() as u64,
454 bytes,
455 })
456}
457
458pub fn import_archive(store: &Path, archive: &Path) -> Result<TransferOutcome> {
460 Ok(import_archive_with_attachments(store, archive)?.transfer)
461}
462
463pub fn import_archive_with_attachments(store: &Path, archive: &Path) -> Result<ImportOutcome> {
465 let staging = tempfile::tempdir()?;
466 let file = std::fs::File::open(archive)
467 .wrap_err_with(|| format!("failed to open {}", archive.display()))?;
468 let mut bundle = tar::Archive::new(file);
469 let mut seen = BTreeSet::new();
470 for entry in bundle.entries()? {
471 let mut entry = entry?;
472 let entry_type = entry.header().entry_type();
473 if !entry_type.is_file() && !entry_type.is_gnu_sparse() {
474 eyre::bail!("cache export contains a non-file entry");
475 }
476 let path = entry.path()?.into_owned();
477 validate_archive_path(&path)?;
478 if !seen.insert(path.clone()) {
479 eyre::bail!("cache export contains duplicate entry {}", path.display());
480 }
481 let destination = staging.path().join(&path);
482 std::fs::create_dir_all(destination.parent().expect("entry has a parent"))?;
483 entry.unpack(&destination)?;
487 }
488 let manifest: ExportManifest =
489 serde_json::from_slice(&std::fs::read(staging.path().join(EXPORT_MANIFEST))?)?;
490 let actions = manifest.actions.iter().cloned().collect::<BTreeSet<_>>();
491 let task_identities = manifest
492 .tasks
493 .iter()
494 .map(|task| task.task.as_str())
495 .collect::<BTreeSet<_>>();
496 if !matches!(manifest.version, LEGACY_EXPORT_VERSION | EXPORT_VERSION)
497 || (manifest.version == LEGACY_EXPORT_VERSION
498 && (!manifest.attachments.is_empty() || !manifest.objects.is_empty()))
499 || manifest.tasks.is_empty()
500 || actions.len() != manifest.actions.len()
501 || task_identities.len() != manifest.tasks.len()
502 || manifest.tasks.iter().any(|task| !task.validate())
503 || manifest
504 .actions
505 .iter()
506 .any(|action| action.algorithm != "blake3" || action.validate().is_err())
507 || manifest.tasks.iter().any(|task| {
508 task.predictions
509 .iter()
510 .any(|prediction| !actions.contains(&prediction.action))
511 })
512 || manifest
513 .attachments
514 .keys()
515 .any(|name| !valid_attachment_name(name))
516 || manifest.attachments.values().any(|digest| {
517 digest.algorithm != "blake3"
518 || digest.validate().is_err()
519 || !manifest.objects.contains(digest)
520 })
521 || manifest.objects.iter().collect::<BTreeSet<_>>().len() != manifest.objects.len()
522 || manifest
523 .objects
524 .iter()
525 .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
526 {
527 eyre::bail!("unsupported or invalid cache export manifest");
528 }
529 let (mut objects, result_paths) = strict_closure(staging.path(), &actions)
530 .wrap_err("cache export is incomplete or corrupt")?;
531 let staged_cas = LocalCas::new(staging.path());
532 for digest in &manifest.objects {
533 require_object(&staged_cas, &mut objects, digest)
534 .wrap_err("cache export attachment is incomplete or corrupt")?;
535 }
536
537 let cas = LocalCas::new(store);
538 for path in &objects {
539 let relative = path.strip_prefix(staging.path())?;
540 let source = staging.path().join(relative);
541 let digest = addressed_digest(staging.path(), &source, false)
542 .ok_or_else(|| eyre::eyre!("invalid cache object path {}", relative.display()))?;
543 cas.adopt_verified_file(&digest, &source)?;
547 }
548 let action_cache = mbx_cache_core::LocalActionCache::new(store);
549 for path in &result_paths {
550 let result: RemoteActionResult = serde_json::from_slice(&std::fs::read(path)?)?;
551 action_cache.store(&result)?;
552 }
553 for task in manifest.tasks {
554 merge_imported_manifest(store, task)?;
555 }
556 Ok(ImportOutcome {
557 transfer: TransferOutcome {
558 actions: actions.len() as u64,
559 objects: objects.len() as u64,
560 bytes: std::fs::metadata(archive)?.len(),
561 },
562 attachments: manifest.attachments,
563 })
564}
565
566fn validate_export_additions(additions: &ExportAdditions) -> Result<()> {
567 if additions
568 .attachments
569 .keys()
570 .any(|name| !valid_attachment_name(name))
571 || additions
572 .attachments
573 .values()
574 .any(|digest| !additions.objects.contains(digest))
575 || additions
576 .objects
577 .iter()
578 .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
579 {
580 eyre::bail!("invalid cache export attachment");
581 }
582 Ok(())
583}
584
585fn valid_attachment_name(name: &str) -> bool {
586 !name.is_empty()
587 && name.len() <= 128
588 && name
589 .bytes()
590 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
591}
592
593fn strict_closure(
594 store: &Path,
595 actions: &BTreeSet<CacheDigest>,
596) -> Result<(BTreeSet<PathBuf>, BTreeSet<PathBuf>)> {
597 let cas = LocalCas::new(store);
598 let action_cache = mbx_cache_core::LocalActionCache::new(store);
599 let mut objects = BTreeSet::new();
600 let mut results = BTreeSet::new();
601 let mut directories = BTreeSet::new();
602 for action in actions {
603 let result = action_cache
604 .find(action)?
605 .ok_or_else(|| eyre::eyre!("action result is missing for {}", action.hash))?;
606 results.insert(action_cache.path_for(action)?);
607 require_object(&cas, &mut objects, &result.action)?;
608 if let Some(metadata) = &result.metadata {
609 require_object(&cas, &mut objects, metadata)?;
610 let captured: CapturedOutput =
611 serde_json::from_slice(&std::fs::read(cas.path_for(metadata)?)?)
612 .wrap_err("action metadata is invalid")?;
613 require_object(&cas, &mut objects, &captured.stdout)?;
614 require_object(&cas, &mut objects, &captured.stderr)?;
615 }
616 if let Some(root) = &result.output_root {
617 require_object(&cas, &mut objects, root)?;
618 let mut pending = vec![root.clone()];
619 while let Some(digest) = pending.pop() {
620 if !directories.insert(digest.clone()) {
621 continue;
622 }
623 let directory: CacheDirectory =
624 serde_json::from_slice(&std::fs::read(cas.path_for(&digest)?)?)
625 .wrap_err("output directory is invalid")?;
626 for file in directory.files {
627 require_object(&cas, &mut objects, &file.digest)?;
628 }
629 for child in directory.directories {
630 require_object(&cas, &mut objects, &child.digest)?;
631 pending.push(child.digest);
632 }
633 }
634 }
635 }
636 Ok((objects, results))
637}
638
639#[derive(Deserialize)]
640struct CapturedOutput {
641 stdout: CacheDigest,
642 stderr: CacheDigest,
643}
644
645fn merge_imported_manifest(destination: &Path, mut imported: TaskActionManifest) -> Result<()> {
646 let identity = imported.task.clone();
647 let destination_path = task_manifest_path(destination, &identity);
648 if let Ok(bytes) = std::fs::read(&destination_path)
649 && let Ok(existing) = serde_json::from_slice::<TaskActionManifest>(&bytes)
650 && existing.task == identity
651 && existing.validate()
652 {
653 let mut predictions = imported
654 .predictions
655 .into_iter()
656 .map(|prediction| (prediction.invocation.clone(), prediction))
657 .collect::<BTreeMap<_, _>>();
658 predictions.extend(
659 existing
660 .predictions
661 .iter()
662 .cloned()
663 .map(|prediction| (prediction.invocation.clone(), prediction)),
664 );
665 imported.predictions = predictions.into_values().collect();
666 if !imported.validate() {
667 return Ok(());
668 }
669 }
670 write_atomic(&destination_path, &serde_json::to_vec(&imported)?)
671}
672
673fn require_object(
674 cas: &LocalCas,
675 objects: &mut BTreeSet<PathBuf>,
676 digest: &CacheDigest,
677) -> Result<()> {
678 let expected = cas.path_for(digest)?;
679 if objects.contains(&expected) {
680 return Ok(());
681 }
682 let path = cas
683 .find(digest)?
684 .ok_or_else(|| eyre::eyre!("cache object is missing for {}", digest.hash))?;
685 objects.insert(path);
686 Ok(())
687}
688
689fn task_manifest_path(store: &Path, identity: &str) -> PathBuf {
690 store
691 .join("task-manifests/v1")
692 .join(format!("{identity}.json"))
693}
694
695fn append_file(builder: &mut tar::Builder<std::fs::File>, root: &Path, path: &Path) -> Result<()> {
696 let name = path.strip_prefix(root)?;
697 builder.append_path_with_name(path, name)?;
698 Ok(())
699}
700
701fn append_bytes(
702 builder: &mut tar::Builder<std::fs::File>,
703 name: &Path,
704 bytes: &[u8],
705) -> Result<()> {
706 let mut header = tar::Header::new_gnu();
707 header.set_size(bytes.len() as u64);
708 header.set_mode(0o644);
709 header.set_cksum();
710 builder.append_data(&mut header, name, bytes)?;
711 Ok(())
712}
713
714fn validate_archive_path(path: &Path) -> Result<()> {
715 if path.as_os_str().is_empty()
716 || path.is_absolute()
717 || path
718 .components()
719 .any(|component| !matches!(component, Component::Normal(_)))
720 {
721 eyre::bail!("cache export contains unsafe path {}", path.display());
722 }
723 let text = path.to_string_lossy();
724 if text != EXPORT_MANIFEST
725 && !text.starts_with("cas/v1/")
726 && !text.starts_with("action-results/v1/")
727 {
728 eyre::bail!("cache export contains unexpected path {}", path.display());
729 }
730 Ok(())
731}
732
733#[derive(Debug, Clone, Serialize, Deserialize)]
740#[serde(deny_unknown_fields)]
741struct CheckoutRecord {
742 version: u8,
743 workspace_root: PathBuf,
744 target_dir: PathBuf,
745 updated_secs: u64,
746}
747
748pub fn stats(store: &Path) -> Result<StoreStats> {
750 let objects = walk_files(&store.join(CAS_DIR))?;
751 let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
752 let checkouts = scan_checkouts(store)?;
753 Ok(StoreStats {
754 objects: objects.len() as u64,
755 object_bytes: objects.iter().map(|entry| entry.size).sum(),
756 action_results: results.len() as u64,
757 action_result_bytes: results.iter().map(|entry| entry.size).sum(),
758 live_checkouts: checkouts.live_records,
759 stale_checkouts: checkouts.stale_records.len() as u64,
760 })
761}
762
763fn checkout_target_bytes(sizes: &mut BTreeMap<PathBuf, u64>, record: &CheckoutRecord) -> u64 {
770 if record.target_dir == record.workspace_root {
771 return 0;
772 }
773 cached_tree_bytes(sizes, &record.target_dir)
774}
775
776pub fn projects(store: &Path) -> Result<Vec<ProjectUsage>> {
782 let mut projects: BTreeMap<PathBuf, (BTreeSet<String>, bool, u64)> = BTreeMap::new();
783 let mut target_sizes = BTreeMap::new();
784 let root = store.join(CHECKOUTS_DIR);
785 for identity_entry in read_dir_or_empty(&root)? {
786 let identity = identity_entry.file_name().to_string_lossy().into_owned();
787 if !is_task_identity(&identity) || !identity_entry.file_type()?.is_dir() {
788 continue;
789 }
790 for entry in walk_files(&identity_entry.path())? {
791 let Some(record) = read_checkout_record(&entry.path) else {
792 continue;
793 };
794 let project = projects.entry(record.workspace_root.clone()).or_default();
795 if claim_is_live(store, &record) {
796 project.0.insert(identity.clone());
797 project.1 = true;
798 }
799 project.2 = project
800 .2
801 .max(checkout_target_bytes(&mut target_sizes, &record));
802 }
803 }
804 let action_cache = mbx_cache_core::LocalActionCache::new(store);
805 let mut usages = Vec::new();
806 for (workspace_root, (identities, live, target_bytes)) in projects {
807 let mut paths = rooted_objects(store, &identities)?;
808 for identity in &identities {
809 for action in task_manifest_actions(store, identity).unwrap_or_default() {
810 if let Ok(path) = action_cache.path_for(&action) {
811 paths.insert(path);
812 }
813 }
814 }
815 let action_bytes = paths
816 .iter()
817 .filter_map(|path| std::fs::metadata(path).ok())
818 .map(|metadata| metadata.len())
819 .sum();
820 usages.push(ProjectUsage {
821 workspace_root,
822 identities: identities.len() as u64,
823 action_bytes,
824 target_bytes,
825 live,
826 });
827 }
828 usages.sort_by(|left, right| {
829 right
830 .action_bytes
831 .saturating_add(right.target_bytes)
832 .cmp(&left.action_bytes.saturating_add(left.target_bytes))
833 .then_with(|| left.workspace_root.cmp(&right.workspace_root))
834 });
835 Ok(usages)
836}
837
838pub fn largest(store: &Path, limit: usize) -> Result<Vec<LargestEntry>> {
840 let mut entries = Vec::new();
841 for (kind, root, action_result) in [
842 ("object", store.join(CAS_DIR), false),
843 ("action result", store.join(ACTION_RESULTS_DIR), true),
844 ] {
845 entries.extend(
846 walk_files(&root)?
847 .into_iter()
848 .filter(|entry| addressed_digest(store, &entry.path, action_result).is_some())
849 .map(|entry| LargestEntry {
850 kind,
851 path: entry.path,
852 bytes: entry.size,
853 }),
854 );
855 }
856 entries.sort_by(|left, right| {
857 right
858 .bytes
859 .cmp(&left.bytes)
860 .then_with(|| left.path.cmp(&right.path))
861 });
862 entries.truncate(limit);
863 Ok(entries)
864}
865
866pub fn verify(store: &Path) -> Result<VerifyOutcome> {
868 let cas = LocalCas::new(store);
869 let action_cache = mbx_cache_core::LocalActionCache::new(store);
870 let virtually_removed = HashSet::new();
873 let mut outcome = VerifyOutcome::default();
874 for entry in walk_files(&store.join(CAS_DIR))? {
875 let Some(digest) = addressed_digest(store, &entry.path, false) else {
876 continue;
877 };
878 outcome.checked_objects += 1;
879 if !matches!(cas.find(&digest), Ok(Some(_))) {
880 outcome.problems.push(entry.path);
881 }
882 }
883 for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
884 let Some(digest) = addressed_digest(store, &entry.path, true) else {
885 continue;
886 };
887 outcome.checked_action_results += 1;
888 if !matches!(action_cache.find(&digest), Ok(Some(_)))
889 || action_result_is_dangling(&cas, &entry.path, &virtually_removed)?
890 {
891 outcome.problems.push(entry.path);
892 }
893 }
894 outcome.problems.sort();
895 Ok(outcome)
896}
897
898pub fn remove_project(store: &Path, workspace_root: &Path) -> Result<RemoveProjectOutcome> {
900 let mut outcome = RemoveProjectOutcome::default();
901 for entry in walk_files(&store.join(CHECKOUTS_DIR))? {
902 if read_checkout_record(&entry.path)
903 .is_some_and(|record| record.workspace_root == workspace_root)
904 {
905 std::fs::remove_file(&entry.path)?;
906 outcome.removed_checkout_records += 1;
907 if let Some(parent) = entry.path.parent() {
908 let _ = std::fs::remove_dir(parent);
909 }
910 }
911 }
912 match std::fs::remove_file(latest_receipt_path(store, workspace_root)) {
913 Ok(()) => {}
914 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
915 Err(error) => return Err(error.into()),
916 }
917 Ok(outcome)
918}
919
920pub fn record_checkout(
932 store: &Path,
933 identity: &str,
934 workspace_root: &Path,
935 target_dir: &Path,
936) -> Result<()> {
937 let record = CheckoutRecord {
938 version: CHECKOUT_RECORD_VERSION,
939 workspace_root: workspace_root.to_path_buf(),
940 target_dir: target_dir.to_path_buf(),
941 updated_secs: SystemTime::now()
942 .duration_since(UNIX_EPOCH)
943 .map(|since| since.as_secs())
944 .unwrap_or_default(),
945 };
946 let mut contents = serde_json::to_vec(&record)?;
947 contents.push(b'\n');
948 write_advisory(
951 &checkout_record_path(store, identity, workspace_root),
952 &contents,
953 )
954}
955
956fn checkout_record_path(store: &Path, identity: &str, workspace_root: &Path) -> PathBuf {
957 let key = CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash;
961 store
962 .join(CHECKOUTS_DIR)
963 .join(identity)
964 .join(format!("{key}.json"))
965}
966
967pub fn gc(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
977 gc_with_mode(store, max_bytes, false)
978}
979
980pub fn gc_dry_run(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
982 gc_with_mode(store, max_bytes, true)
983}
984
985fn gc_with_mode(store: &Path, max_bytes: u64, dry_run: bool) -> Result<GcOutcome> {
986 let mut objects = walk_files(&store.join(CAS_DIR))?;
987 let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
988 let mut live_bytes = objects
989 .iter()
990 .chain(results.iter())
991 .map(|entry| entry.size)
992 .sum::<u64>();
993
994 let mut outcome = GcOutcome::default();
995
996 let checkouts = scan_checkouts(store)?;
999 for path in &checkouts.stale_records {
1000 if dry_run || matches!(remove(path)?, Removal::Removed) {
1001 outcome.removed_checkout_records += 1;
1002 }
1003 if !dry_run && let Some(parent) = path.parent() {
1006 let _ = std::fs::remove_dir(parent);
1007 }
1008 }
1009
1010 let mut virtually_removed = HashSet::new();
1011 if live_bytes > max_bytes {
1012 let mut rooted = rooted_objects(store, &checkouts.live_identities)?;
1016 rooted.extend(rooted_action_objects(
1023 store,
1024 grouped_receipt_actions(store)?,
1025 ));
1026 objects.sort_by_cached_key(|entry| (rooted.contains(&entry.path), entry.used));
1030 let evicted = evict_objects(&objects, &rooted, live_bytes, max_bytes, |path| {
1031 if dry_run {
1032 virtually_removed.insert(path.to_path_buf());
1033 Ok(Removal::Removed)
1034 } else {
1035 remove(path)
1036 }
1037 })?;
1038 live_bytes = evicted.remaining_bytes;
1039 outcome.removed_objects += evicted.removed_objects;
1040 outcome.removed_bytes += evicted.removed_bytes;
1041 }
1042
1043 let cas = LocalCas::new(store);
1048 for entry in &results {
1049 if action_result_is_dangling(&cas, &entry.path, &virtually_removed)? {
1050 match if dry_run {
1051 Removal::Removed
1052 } else {
1053 remove(&entry.path)?
1054 } {
1055 Removal::Removed => {
1056 live_bytes = live_bytes.saturating_sub(entry.size);
1057 outcome.removed_action_results += 1;
1058 outcome.removed_bytes += entry.size;
1059 }
1060 Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
1061 Removal::Blocked => {}
1062 }
1063 }
1064 }
1065
1066 let sessions = prune_sessions(store, dry_run)?;
1067 outcome.removed_session_streams += sessions.removed_streams;
1068 outcome.removed_bytes += sessions.removed_bytes;
1069
1070 outcome.remaining_bytes = live_bytes;
1071 Ok(outcome)
1072}
1073
1074#[derive(Debug, Default, PartialEq, Eq)]
1075struct SessionPrune {
1076 removed_streams: u64,
1077 removed_bytes: u64,
1078}
1079
1080fn prune_sessions(store: &Path, dry_run: bool) -> Result<SessionPrune> {
1081 let mut prune = SessionPrune::default();
1082 let ids = events::session_ids(store);
1083 let surplus = ids.len().saturating_sub(MAX_SESSIONS);
1084 for (index, id) in ids.iter().enumerate() {
1085 let paths = events::session_paths(store, id);
1086 let metadata = match std::fs::metadata(&paths.events) {
1087 Ok(metadata) => metadata,
1088 Err(_) => continue,
1089 };
1090 let stale = metadata
1091 .modified()
1092 .ok()
1093 .and_then(|at| SystemTime::now().duration_since(at).ok())
1094 .is_some_and(|age| age > SESSION_RETENTION);
1095 if index >= surplus && !stale {
1096 continue;
1097 }
1098 if events::session_is_live(store, id) {
1099 continue;
1100 }
1101 if dry_run {
1102 prune.removed_streams += 1;
1103 prune.removed_bytes += metadata.len();
1104 continue;
1105 }
1106 if matches!(remove(&paths.events)?, Removal::Removed) {
1107 prune.removed_streams += 1;
1108 prune.removed_bytes += metadata.len();
1109 let _ = std::fs::remove_file(&paths.lock);
1110 }
1111 }
1112 if !dry_run {
1113 for lock in events::orphaned_locks(store) {
1114 let _ = std::fs::remove_file(lock);
1115 }
1116 }
1117 Ok(prune)
1118}
1119
1120pub fn sweep_if_due(store: &Path, max_bytes: u64, interval: Duration) -> Result<Option<GcOutcome>> {
1126 if !claim_sweep(store, interval)? {
1127 return Ok(None);
1128 }
1129 gc(store, max_bytes).map(Some)
1130}
1131
1132pub fn claim_sweep(store: &Path, interval: Duration) -> Result<bool> {
1134 let lock_path = store.join(SWEEP_LOCK);
1135 std::fs::create_dir_all(lock_path.parent().expect("sweep lock has a parent"))?;
1136 let mut lock = fslock::LockFile::open(&lock_path)?;
1137 lock.lock()?;
1138
1139 let stamp = store.join(SWEEP_STAMP);
1140 if let Ok(metadata) = std::fs::metadata(&stamp)
1141 && let Ok(modified) = metadata.modified()
1142 && let Ok(since) = modified.elapsed()
1143 && since < interval
1144 {
1145 return Ok(false);
1146 }
1147 write_atomic(&stamp, b"")?;
1148 Ok(true)
1149}
1150
1151struct CheckoutScan {
1153 live_identities: BTreeSet<String>,
1154 live_records: u64,
1155 stale_records: Vec<PathBuf>,
1156}
1157
1158fn scan_checkouts(store: &Path) -> Result<CheckoutScan> {
1159 let root = store.join(CHECKOUTS_DIR);
1160 let mut scan = CheckoutScan {
1161 live_identities: BTreeSet::new(),
1162 live_records: 0,
1163 stale_records: Vec::new(),
1164 };
1165 let listing = match std::fs::read_dir(&root) {
1166 Ok(listing) => listing,
1167 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(scan),
1168 Err(error) => {
1169 return Err(error).wrap_err_with(|| format!("failed to read {}", root.display()));
1170 }
1171 };
1172 for entry in listing {
1173 let entry = entry?;
1174 let name = entry.file_name().to_string_lossy().into_owned();
1175 if !is_task_identity(&name) || !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
1181 continue;
1182 }
1183 for record in walk_files(&entry.path())? {
1184 match read_checkout_record(&record.path) {
1185 Some(checkout) if claim_is_live(store, &checkout) => {
1186 scan.live_identities.insert(name.clone());
1187 scan.live_records += 1;
1188 }
1189 Some(_) => scan.stale_records.push(record.path),
1190 None => {}
1193 }
1194 }
1195 }
1196 Ok(scan)
1197}
1198
1199fn read_checkout_record(path: &Path) -> Option<CheckoutRecord> {
1200 let bytes = std::fs::read(path).ok()?;
1201 let record = serde_json::from_slice::<CheckoutRecord>(&bytes).ok()?;
1202 (record.version == CHECKOUT_RECORD_VERSION).then_some(record)
1203}
1204
1205fn grouped_receipt_actions(store: &Path) -> Result<BTreeSet<CacheDigest>> {
1207 let root = store.join(BUILD_RECEIPTS_DIR).join("groups");
1208 Ok(walk_files(&root)?
1209 .into_iter()
1210 .filter_map(|entry| read_build_receipt(&entry.path))
1211 .flat_map(|receipt| receipt.predictions.into_iter())
1212 .map(|prediction| prediction.action)
1213 .collect())
1214}
1215
1216fn claim_is_live(store: &Path, record: &CheckoutRecord) -> bool {
1219 checkout_is_live_on(store, &record.workspace_root) && !claim_has_expired(record)
1220}
1221
1222fn claim_has_expired(record: &CheckoutRecord) -> bool {
1223 let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
1224 return false;
1225 };
1226 now.as_secs()
1227 .saturating_sub(record.updated_secs)
1228 .gt(&CHECKOUT_RETENTION.as_secs())
1229}
1230
1231pub fn checkout_is_live_on(store: &Path, workspace_root: &Path) -> bool {
1244 if !matches!(workspace_root.try_exists(), Ok(false)) {
1245 return true;
1246 }
1247
1248 let Ok(store_metadata) = std::fs::metadata(store) else {
1249 return true;
1250 };
1251 for ancestor in workspace_root.ancestors().skip(1) {
1252 match std::fs::metadata(ancestor) {
1253 Ok(ancestor_metadata) => {
1254 return !same_filesystem(store, &store_metadata, ancestor, &ancestor_metadata);
1255 }
1256 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1257 Err(_) => return true,
1258 }
1259 }
1260 true
1261}
1262
1263pub fn checkout_is_live(workspace_root: &Path) -> bool {
1269 let Some(parent) = workspace_root.parent() else {
1270 return true;
1271 };
1272 checkout_is_live_on(parent, workspace_root)
1273}
1274
1275#[cfg(unix)]
1276fn same_filesystem(
1277 _a_path: &Path,
1278 a: &std::fs::Metadata,
1279 _b_path: &Path,
1280 b: &std::fs::Metadata,
1281) -> bool {
1282 use std::os::unix::fs::MetadataExt as _;
1283 a.dev() == b.dev()
1284}
1285
1286#[cfg(windows)]
1287fn same_filesystem(
1288 a_path: &Path,
1289 _a: &std::fs::Metadata,
1290 b_path: &Path,
1291 _b: &std::fs::Metadata,
1292) -> bool {
1293 windows_volume_name(a_path).is_some_and(|volume| windows_volume_name(b_path) == Some(volume))
1294}
1295
1296#[cfg(windows)]
1297fn windows_volume_name(path: &Path) -> Option<Vec<u16>> {
1298 use std::os::windows::ffi::OsStrExt as _;
1299 use windows_sys::Win32::Storage::FileSystem::{
1300 GetVolumeNameForVolumeMountPointW, GetVolumePathNameW,
1301 };
1302
1303 let path = path
1304 .as_os_str()
1305 .encode_wide()
1306 .chain(Some(0))
1307 .collect::<Vec<_>>();
1308 let mut mount = vec![0_u16; 261];
1312 if unsafe { GetVolumePathNameW(path.as_ptr(), mount.as_mut_ptr(), mount.len() as u32) } == 0 {
1315 return None;
1316 }
1317 let mut volume = vec![0_u16; 261];
1318 if unsafe {
1321 GetVolumeNameForVolumeMountPointW(mount.as_ptr(), volume.as_mut_ptr(), volume.len() as u32)
1322 } == 0
1323 {
1324 return None;
1325 }
1326 volume.truncate(volume.iter().position(|unit| *unit == 0)?);
1327 Some(volume)
1328}
1329
1330#[cfg(not(any(unix, windows)))]
1331fn same_filesystem(
1332 _a_path: &Path,
1333 _a: &std::fs::Metadata,
1334 _b_path: &Path,
1335 _b: &std::fs::Metadata,
1336) -> bool {
1337 false
1338}
1339
1340fn rooted_objects(store: &Path, identities: &BTreeSet<String>) -> Result<HashSet<PathBuf>> {
1348 let mut actions = BTreeSet::new();
1349 for identity in identities {
1350 let manifest_actions = match task_manifest_actions(store, identity) {
1353 Ok(actions) => actions,
1354 Err(error) => {
1355 log::debug!("could not read the manifest for {identity}: {error}");
1356 continue;
1357 }
1358 };
1359 actions.extend(manifest_actions);
1360 }
1361 Ok(rooted_action_objects(store, actions))
1362}
1363
1364fn rooted_action_objects(
1366 store: &Path,
1367 actions: impl IntoIterator<Item = CacheDigest>,
1368) -> HashSet<PathBuf> {
1369 let cas = LocalCas::new(store);
1370 let mut rooted = HashSet::new();
1371 let mut visited = BTreeSet::new();
1372 for action in actions {
1373 let Some(result) = read_action_result(store, &action) else {
1374 continue;
1375 };
1376 root_digest(&cas, &mut rooted, &result.action);
1377 if let Some(metadata) = &result.metadata {
1378 root_digest(&cas, &mut rooted, metadata);
1379 if let Some(rustc) = read_rustc_metadata(&cas, metadata) {
1386 root_digest(&cas, &mut rooted, &rustc.stdout);
1387 root_digest(&cas, &mut rooted, &rustc.stderr);
1388 }
1389 }
1390 if let Some(output_root) = &result.output_root {
1391 root_digest(&cas, &mut rooted, output_root);
1392 root_tree(&cas, output_root, &mut rooted, &mut visited);
1393 }
1394 }
1395 rooted
1396}
1397
1398fn read_action_result(store: &Path, action: &CacheDigest) -> Option<RemoteActionResult> {
1399 let path = mbx_cache_core::LocalActionCache::new(store)
1400 .path_for(action)
1401 .ok()?;
1402 let bytes = std::fs::read(path).ok()?;
1403 serde_json::from_slice(&bytes).ok()
1404}
1405
1406fn root_tree(
1407 cas: &LocalCas,
1408 output_root: &CacheDigest,
1409 rooted: &mut HashSet<PathBuf>,
1410 visited: &mut BTreeSet<CacheDigest>,
1411) {
1412 let mut pending = vec![output_root.clone()];
1413 while let Some(digest) = pending.pop() {
1414 if !visited.insert(digest.clone()) {
1415 continue;
1416 }
1417 let Some(directory) = read_directory(cas, &digest) else {
1418 continue;
1419 };
1420 for file in &directory.files {
1421 root_digest(cas, rooted, &file.digest);
1422 }
1423 for child in &directory.directories {
1424 root_digest(cas, rooted, &child.digest);
1425 pending.push(child.digest.clone());
1426 }
1427 }
1428}
1429
1430fn read_rustc_metadata(cas: &LocalCas, digest: &CacheDigest) -> Option<RustcMetadata> {
1431 let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
1432 serde_json::from_slice(&bytes).ok()
1433}
1434
1435fn read_directory(cas: &LocalCas, digest: &CacheDigest) -> Option<CacheDirectory> {
1436 let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
1437 serde_json::from_slice(&bytes).ok()
1438}
1439
1440fn root_digest(cas: &LocalCas, rooted: &mut HashSet<PathBuf>, digest: &CacheDigest) {
1441 if let Ok(path) = cas.path_for(digest) {
1442 rooted.insert(path);
1443 }
1444}
1445
1446#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1447enum Removal {
1448 Removed,
1449 Missing,
1450 Blocked,
1451}
1452
1453fn remove(path: &Path) -> Result<Removal> {
1454 match std::fs::remove_file(path) {
1455 Ok(()) => Ok(Removal::Removed),
1456 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Removal::Missing),
1458 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
1463 log::debug!("could not evict {}: {error}", path.display());
1464 Ok(Removal::Blocked)
1465 }
1466 Err(error) => Err(error).wrap_err_with(|| format!("failed to evict {}", path.display())),
1467 }
1468}
1469
1470#[derive(Debug, Default, PartialEq, Eq)]
1471struct ObjectEvictions {
1472 removed_objects: u64,
1473 removed_bytes: u64,
1474 remaining_bytes: u64,
1475}
1476
1477fn evict_objects(
1480 objects: &[Entry],
1481 rooted: &HashSet<PathBuf>,
1482 mut live_bytes: u64,
1483 max_bytes: u64,
1484 mut remove_file: impl FnMut(&Path) -> Result<Removal>,
1485) -> Result<ObjectEvictions> {
1486 let mut outcome = ObjectEvictions::default();
1487 let mut blocked_unrooted = false;
1488 for entry in objects {
1489 if live_bytes <= max_bytes {
1490 break;
1491 }
1492 let is_rooted = rooted.contains(&entry.path);
1493 if is_rooted && blocked_unrooted {
1494 break;
1498 }
1499 match remove_file(&entry.path)? {
1500 Removal::Removed => {
1501 live_bytes = live_bytes.saturating_sub(entry.size);
1502 outcome.removed_objects += 1;
1503 outcome.removed_bytes += entry.size;
1504 }
1505 Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
1506 Removal::Blocked if !is_rooted => blocked_unrooted = true,
1507 Removal::Blocked => {}
1508 }
1509 }
1510 outcome.remaining_bytes = live_bytes;
1511 Ok(outcome)
1512}
1513
1514fn action_result_is_dangling(
1532 cas: &LocalCas,
1533 path: &Path,
1534 virtually_removed: &HashSet<PathBuf>,
1535) -> Result<bool> {
1536 let bytes = match std::fs::read(path) {
1537 Ok(bytes) => bytes,
1538 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1539 Err(error) => {
1540 return Err(error).wrap_err_with(|| format!("failed to read {}", path.display()));
1541 }
1542 };
1543 let Ok(result) = serde_json::from_slice::<RemoteActionResult>(&bytes) else {
1545 return Ok(false);
1546 };
1547 for digest in [
1548 Some(&result.action),
1549 result.metadata.as_ref(),
1550 result.output_root.as_ref(),
1551 ]
1552 .into_iter()
1553 .flatten()
1554 {
1555 match cas.path_for(digest) {
1556 Ok(path) => {
1557 if virtually_removed.contains(&path) || !path.exists() {
1558 return Ok(true);
1559 }
1560 }
1561 Err(_) => return Ok(true),
1563 }
1564 }
1565 Ok(false)
1566}
1567
1568struct Entry {
1569 path: PathBuf,
1570 size: u64,
1571 used: SystemTime,
1572}
1573
1574fn walk_files(root: &Path) -> Result<Vec<Entry>> {
1575 let mut entries = Vec::new();
1576 let mut pending = vec![root.to_path_buf()];
1577 while let Some(directory) = pending.pop() {
1578 let listing = match std::fs::read_dir(&directory) {
1579 Ok(listing) => listing,
1580 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1581 Err(error) => {
1582 return Err(error)
1583 .wrap_err_with(|| format!("failed to read {}", directory.display()));
1584 }
1585 };
1586 for entry in listing {
1587 let entry = entry?;
1588 let metadata = match entry.metadata() {
1589 Ok(metadata) => metadata,
1590 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1592 Err(error) => return Err(error.into()),
1593 };
1594 if metadata.is_dir() {
1595 pending.push(entry.path());
1596 } else if metadata.is_file() {
1597 let used = metadata
1598 .accessed()
1599 .or_else(|_| metadata.modified())
1600 .unwrap_or(SystemTime::UNIX_EPOCH);
1601 entries.push(Entry {
1602 path: entry.path(),
1603 size: metadata.len(),
1604 used,
1605 });
1606 }
1607 }
1608 }
1609 Ok(entries)
1610}
1611
1612fn read_dir_or_empty(root: &Path) -> Result<Vec<std::fs::DirEntry>> {
1613 match std::fs::read_dir(root) {
1614 Ok(entries) => entries
1615 .collect::<std::io::Result<Vec<_>>>()
1616 .map_err(Into::into),
1617 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
1618 Err(error) => Err(error).wrap_err_with(|| format!("failed to read {}", root.display())),
1619 }
1620}
1621
1622fn digest_from_path(path: &Path, action_result: bool) -> Option<CacheDigest> {
1623 let name = path.file_name()?.to_str()?;
1624 let name = if action_result {
1625 name.strip_suffix(".json")?
1626 } else {
1627 name
1628 };
1629 let (hash, size) = name.rsplit_once('-')?;
1630 let algorithm = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
1631 let digest = CacheDigest {
1632 algorithm,
1633 hash: hash.to_string(),
1634 size: size.parse().ok()?,
1635 };
1636 digest.validate().ok()?;
1637 Some(digest)
1638}
1639
1640fn addressed_digest(store: &Path, path: &Path, action_result: bool) -> Option<CacheDigest> {
1641 let digest = digest_from_path(path, action_result)?;
1642 let canonical = if action_result {
1643 mbx_cache_core::LocalActionCache::new(store)
1644 .path_for(&digest)
1645 .ok()?
1646 } else {
1647 LocalCas::new(store).path_for(&digest).ok()?
1648 };
1649 (canonical == path).then_some(digest)
1650}
1651
1652fn cached_tree_bytes(cache: &mut BTreeMap<PathBuf, u64>, root: &Path) -> u64 {
1653 *cache
1654 .entry(root.to_path_buf())
1655 .or_insert_with(|| tree_bytes(root))
1656}
1657
1658fn tree_bytes(root: &Path) -> u64 {
1659 let Ok(root_metadata) = std::fs::metadata(root) else {
1660 return 0;
1661 };
1662 if root_metadata.is_file() {
1663 return root_metadata.len();
1664 }
1665 let Ok(entries) = std::fs::read_dir(root) else {
1666 return 0;
1667 };
1668 let mut bytes = 0_u64;
1669 let mut pending = entries
1670 .filter_map(std::result::Result::ok)
1671 .map(|entry| entry.path())
1672 .collect::<Vec<_>>();
1673 while let Some(path) = pending.pop() {
1674 let Ok(metadata) = std::fs::symlink_metadata(&path) else {
1675 continue;
1676 };
1677 if metadata.is_file() {
1678 bytes = bytes.saturating_add(metadata.len());
1679 } else if metadata.is_dir()
1680 && let Ok(entries) = std::fs::read_dir(path)
1681 {
1682 pending.extend(
1683 entries
1684 .filter_map(std::result::Result::ok)
1685 .map(|entry| entry.path()),
1686 );
1687 }
1688 }
1689 bytes
1690}
1691
1692#[cfg(test)]
1693#[path = "store_tests.rs"]
1694mod tests;
1695
1696fn write_advisory(path: &Path, contents: &[u8]) -> Result<()> {
1699 let parent = path
1700 .parent()
1701 .filter(|parent| !parent.as_os_str().is_empty())
1702 .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
1703 std::fs::create_dir_all(parent)
1704 .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
1705 let mut temporary = tempfile::Builder::new()
1706 .prefix(".mbx-")
1707 .tempfile_in(parent)?;
1708 use std::io::Write as _;
1709 temporary.write_all(contents)?;
1710 temporary
1711 .persist(path)
1712 .map_err(|error| error.error)
1713 .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
1714 Ok(())
1715}
1716
1717fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
1718 let parent = path
1719 .parent()
1720 .filter(|parent| !parent.as_os_str().is_empty())
1721 .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
1722 std::fs::create_dir_all(parent)
1723 .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
1724 let mut temporary = tempfile::Builder::new()
1725 .prefix(".mbx-")
1726 .tempfile_in(parent)?;
1727 use std::io::Write as _;
1728 temporary.write_all(contents)?;
1729 temporary.as_file_mut().sync_all()?;
1730 temporary
1731 .persist(path)
1732 .map_err(|error| error.error)
1733 .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
1734 Ok(())
1735}