1mod events;
13
14use eyre::{Context, Result};
15use mbx_cache_core::{
16 ActionPrediction, CacheDigest, CacheDirectory, LocalCas, RemoteActionResult, RustcMetadata,
17 TaskActionManifest, is_task_identity, merge_task_action_predictions, task_manifest_actions,
18};
19use serde::{Deserialize, Serialize};
20use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
21use std::path::{Component, Path, PathBuf};
22use std::sync::atomic::{AtomicUsize, Ordering};
23use std::sync::{Arc, Mutex};
24use std::time::{Duration, SystemTime, UNIX_EPOCH};
25
26const CAS_DIR: &str = "cas/v1";
27const ACTION_RESULTS_DIR: &str = "action-results/v1";
28const CHECKOUTS_DIR: &str = "checkouts/v1";
29const SWEEP_STAMP: &str = "gc/v1/last-sweep";
30const SWEEP_LOCK: &str = "gc/v1/sweep.lock";
31const CHECKOUT_RECORD_VERSION: u8 = 1;
32const BUILD_RECEIPTS_DIR: &str = "build-receipts/v1";
33const BUILD_RECEIPT_VERSION: u8 = 1;
34const IMPORT_STAGING_DIR: &str = "import-staging";
35const EXPORT_MANIFEST: &str = "mbx-cache-export-v1.json";
36const EXPORT_VERSION: u8 = 2;
37const LEGACY_EXPORT_VERSION: u8 = 1;
38
39const IMPORT_STAGING_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
40const SESSION_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
41const MAX_SESSIONS: usize = 256;
42
43const CHECKOUT_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60);
53
54#[derive(Debug, Default, PartialEq, Eq)]
55pub struct StoreStats {
56 pub objects: u64,
57 pub object_bytes: u64,
58 pub action_results: u64,
59 pub action_result_bytes: u64,
60 pub live_checkouts: u64,
61 pub stale_checkouts: u64,
66}
67
68impl StoreStats {
69 pub fn total_bytes(&self) -> u64 {
70 self.object_bytes.saturating_add(self.action_result_bytes)
71 }
72}
73
74#[derive(Debug, Default, PartialEq, Eq)]
75pub struct GcOutcome {
76 pub removed_objects: u64,
77 pub removed_action_results: u64,
78 pub removed_checkout_records: u64,
79 pub removed_session_streams: u64,
82 pub removed_bytes: u64,
83 pub remaining_bytes: u64,
84}
85
86#[derive(Debug, PartialEq, Eq)]
87pub struct ProjectUsage {
88 pub workspace_root: PathBuf,
89 pub identities: u64,
90 pub action_bytes: u64,
91 pub target_bytes: u64,
92 pub live: bool,
93}
94
95#[derive(Debug, PartialEq, Eq)]
96pub struct LargestEntry {
97 pub kind: &'static str,
98 pub path: PathBuf,
99 pub bytes: u64,
100}
101
102#[derive(Debug, Default, PartialEq, Eq)]
103pub struct VerifyOutcome {
104 pub checked_objects: u64,
105 pub checked_action_results: u64,
106 pub problems: Vec<PathBuf>,
107}
108
109#[derive(Debug, Default, PartialEq, Eq)]
110pub struct RemoveProjectOutcome {
111 pub removed_checkout_records: u64,
112}
113
114#[derive(Debug, Default, PartialEq, Eq)]
115pub struct TransferOutcome {
116 pub actions: u64,
117 pub objects: u64,
118 pub bytes: u64,
119}
120
121#[derive(Debug)]
123#[non_exhaustive]
124pub struct ImportOutcome {
125 pub transfer: TransferOutcome,
126 pub attachments: BTreeMap<String, CacheDigest>,
127}
128
129#[derive(Debug, Default)]
131pub struct ExportAdditions {
132 pub attachments: BTreeMap<String, CacheDigest>,
134 pub objects: BTreeSet<CacheDigest>,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
140pub struct WorkspaceTarget {
141 pub workspace_root: PathBuf,
142 pub target_dir: PathBuf,
143}
144
145#[derive(Debug, Serialize, Deserialize)]
146#[serde(deny_unknown_fields)]
147struct ExportManifest {
148 version: u8,
149 tasks: Vec<TaskActionManifest>,
150 actions: Vec<CacheDigest>,
151 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
152 attachments: BTreeMap<String, CacheDigest>,
153 #[serde(default, skip_serializing_if = "Vec::is_empty")]
154 objects: Vec<CacheDigest>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
159#[serde(deny_unknown_fields)]
160struct BuildReceipt {
161 version: u8,
162 workspace_root: PathBuf,
163 identity: String,
164 completed_nanos: u64,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
166 group: Option<String>,
167 predictions: Vec<ActionPrediction>,
168}
169
170pub fn record_build_receipt(
172 store: &Path,
173 run: &str,
174 identity: &str,
175 workspace_root: &Path,
176 group: Option<&str>,
177 predictions: Vec<ActionPrediction>,
178) -> Result<()> {
179 if !is_task_identity(run) || !is_task_identity(identity) {
180 eyre::bail!("invalid build receipt identity");
181 }
182 if !(TaskActionManifest {
183 version: 1,
184 task: identity.to_owned(),
185 predictions: predictions.clone(),
186 })
187 .validate()
188 {
189 eyre::bail!("invalid build receipt prediction");
190 }
191 if let Some(group) = group {
192 validate_export_group(group)?;
193 }
194 if group.is_none()
199 && read_build_receipt(&latest_receipt_path(store, workspace_root)).is_some_and(|latest| {
200 latest.identity == identity
201 && latest.workspace_root == workspace_root
202 && latest.group.is_none()
203 && latest.predictions == predictions
204 })
205 {
206 return Ok(());
207 }
208 let key = workspace_key(workspace_root);
209 let lock_path = store
210 .join(BUILD_RECEIPTS_DIR)
211 .join("locks")
212 .join(format!("{key}.lock"));
213 std::fs::create_dir_all(lock_path.parent().expect("receipt lock has a parent"))?;
214 let mut lock = fslock::LockFile::open(&lock_path)?;
215 lock.lock()?;
216 let completed_nanos = SystemTime::now()
217 .duration_since(UNIX_EPOCH)
218 .map(|elapsed| elapsed.as_nanos().try_into().unwrap_or(u64::MAX))
219 .unwrap_or_default();
220 let receipt = BuildReceipt {
221 version: BUILD_RECEIPT_VERSION,
222 workspace_root: workspace_root.to_path_buf(),
223 identity: identity.to_owned(),
224 completed_nanos,
225 group: group.map(str::to_owned),
226 predictions,
227 };
228 let bytes = serde_json::to_vec(&receipt)?;
229 write_atomic(&latest_receipt_path(store, workspace_root), &bytes)?;
230 if let Some(group) = group {
231 write_atomic(&group_receipt_path(store, group, run), &bytes)?;
232 }
233 Ok(())
234}
235
236fn workspace_key(workspace_root: &Path) -> String {
237 CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash
238}
239
240fn latest_receipt_path(store: &Path, workspace_root: &Path) -> PathBuf {
241 store
242 .join(BUILD_RECEIPTS_DIR)
243 .join("checkouts")
244 .join(format!("{}.json", workspace_key(workspace_root)))
245}
246
247fn group_receipt_path(store: &Path, group: &str, run: &str) -> PathBuf {
248 store
249 .join(BUILD_RECEIPTS_DIR)
250 .join("groups")
251 .join(group_key(group))
252 .join(format!("{run}.json"))
253}
254
255fn group_key(group: &str) -> String {
256 CacheDigest::blake3(group.as_bytes()).hash
257}
258
259fn read_build_receipt(path: &Path) -> Option<BuildReceipt> {
260 let bytes = std::fs::read(path).ok()?;
261 let receipt = serde_json::from_slice::<BuildReceipt>(&bytes).ok()?;
262 let valid = TaskActionManifest {
263 version: 1,
264 task: receipt.identity.clone(),
265 predictions: receipt.predictions.clone(),
266 }
267 .validate();
268 (receipt.version == BUILD_RECEIPT_VERSION && valid).then_some(receipt)
269}
270
271fn validate_export_group(group: &str) -> Result<()> {
272 if group.is_empty() || group.len() > 256 || group.chars().any(char::is_control) {
273 eyre::bail!("invalid build export group");
274 }
275 Ok(())
276}
277
278#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
286pub enum ExportForm {
287 #[default]
289 Tar,
290 Directory,
292}
293
294impl std::str::FromStr for ExportForm {
295 type Err = eyre::Report;
296
297 fn from_str(text: &str) -> Result<Self> {
298 match text {
299 "tar" => Ok(Self::Tar),
300 "directory" => Ok(Self::Directory),
301 other => eyre::bail!("unknown export format {other:?}; expected tar or directory"),
302 }
303 }
304}
305
306pub fn export_checkout(
308 store: &Path,
309 workspace_root: &Path,
310 archive: &Path,
311) -> Result<TransferOutcome> {
312 let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
313 .filter(|receipt| receipt.workspace_root == workspace_root)
314 .ok_or_else(|| {
315 eyre::eyre!(
316 "no completed mbx build is recorded for {}",
317 workspace_root.display()
318 )
319 })?;
320 export_receipts(
321 store,
322 vec![receipt],
323 archive,
324 ExportAdditions::default(),
325 ExportForm::Tar,
326 )
327}
328
329pub fn checkout_workspace_target(
331 store: &Path,
332 workspace_root: &Path,
333) -> Result<Option<WorkspaceTarget>> {
334 let Some(receipt) = read_build_receipt(&latest_receipt_path(store, workspace_root))
335 .filter(|receipt| receipt.workspace_root == workspace_root)
336 else {
337 return Ok(None);
338 };
339 Ok(workspace_target_for_receipt(store, &receipt))
340}
341
342pub fn group_workspace_targets(store: &Path, group: &str) -> Result<Vec<WorkspaceTarget>> {
344 validate_export_group(group)?;
345 let root = store
346 .join(BUILD_RECEIPTS_DIR)
347 .join("groups")
348 .join(group_key(group));
349 let targets = walk_files(&root)?
350 .into_iter()
351 .filter_map(|entry| read_build_receipt(&entry.path))
352 .filter(|receipt| receipt.group.as_deref() == Some(group))
353 .filter_map(|receipt| workspace_target_for_receipt(store, &receipt))
354 .collect::<BTreeSet<_>>();
355 Ok(targets.into_iter().collect())
356}
357
358fn workspace_target_for_receipt(store: &Path, receipt: &BuildReceipt) -> Option<WorkspaceTarget> {
359 let record = read_checkout_record(&checkout_record_path(
360 store,
361 &receipt.identity,
362 &receipt.workspace_root,
363 ))?;
364 (record.workspace_root == receipt.workspace_root && record.target_dir != record.workspace_root)
365 .then_some(WorkspaceTarget {
366 workspace_root: record.workspace_root,
367 target_dir: record.target_dir,
368 })
369}
370
371pub fn export_checkout_with(
373 store: &Path,
374 workspace_root: &Path,
375 archive: &Path,
376 additions: ExportAdditions,
377) -> Result<TransferOutcome> {
378 let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
379 .filter(|receipt| receipt.workspace_root == workspace_root)
380 .ok_or_else(|| {
381 eyre::eyre!(
382 "no completed mbx build is recorded for {}",
383 workspace_root.display()
384 )
385 })?;
386 export_receipts(store, vec![receipt], archive, additions, ExportForm::Tar)
387}
388
389pub fn export_checkout_as(
391 store: &Path,
392 workspace_root: &Path,
393 destination: &Path,
394 additions: ExportAdditions,
395 form: ExportForm,
396) -> Result<TransferOutcome> {
397 let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
398 .filter(|receipt| receipt.workspace_root == workspace_root)
399 .ok_or_else(|| {
400 eyre::eyre!(
401 "no completed mbx build is recorded for {}",
402 workspace_root.display()
403 )
404 })?;
405 export_receipts(store, vec![receipt], destination, additions, form)
406}
407
408pub fn export_group(store: &Path, group: &str, archive: &Path) -> Result<TransferOutcome> {
410 export_group_with(store, group, archive, ExportAdditions::default())
411}
412
413pub fn export_group_with(
415 store: &Path,
416 group: &str,
417 archive: &Path,
418 additions: ExportAdditions,
419) -> Result<TransferOutcome> {
420 export_group_as(store, group, archive, additions, ExportForm::Tar)
421}
422
423pub fn export_group_as(
425 store: &Path,
426 group: &str,
427 destination: &Path,
428 additions: ExportAdditions,
429 form: ExportForm,
430) -> Result<TransferOutcome> {
431 validate_export_group(group)?;
432 let root = store
433 .join(BUILD_RECEIPTS_DIR)
434 .join("groups")
435 .join(group_key(group));
436 let receipts = walk_files(&root)?
437 .into_iter()
438 .filter_map(|entry| read_build_receipt(&entry.path).map(|receipt| (entry.path, receipt)))
439 .filter(|(_, receipt)| receipt.group.as_deref() == Some(group))
440 .collect::<Vec<_>>();
441 if receipts.is_empty() {
442 eyre::bail!("no completed mbx builds are recorded for export group {group:?}");
443 }
444 let outcome = export_receipts(
445 store,
446 receipts
447 .iter()
448 .map(|(_, receipt)| receipt.clone())
449 .collect(),
450 destination,
451 additions,
452 form,
453 )?;
454 for (path, _) in receipts {
459 std::fs::remove_file(path)?;
460 }
461 let _ = std::fs::remove_dir(root);
462 Ok(outcome)
463}
464
465fn export_receipts(
466 store: &Path,
467 mut receipts: Vec<BuildReceipt>,
468 archive: &Path,
469 additions: ExportAdditions,
470 form: ExportForm,
471) -> Result<TransferOutcome> {
472 receipts.sort_by(|left, right| {
473 left.completed_nanos
474 .cmp(&right.completed_nanos)
475 .then_with(|| {
476 fn key(prediction: &ActionPrediction) -> (&CacheDigest, &CacheDigest, &str, &str) {
479 (
480 &prediction.invocation,
481 &prediction.action,
482 &prediction.adapter,
483 &prediction.payload,
484 )
485 }
486 left.predictions
487 .iter()
488 .map(key)
489 .cmp(right.predictions.iter().map(key))
490 })
491 });
492 let mut actions = BTreeSet::new();
493 let mut tasks = BTreeMap::new();
494 for receipt in receipts {
495 actions.extend(
496 receipt
497 .predictions
498 .iter()
499 .map(|prediction| prediction.action.clone()),
500 );
501 tasks
504 .entry(receipt.identity)
505 .or_insert_with(Vec::new)
506 .extend(receipt.predictions);
507 }
508 let tasks = tasks
509 .into_iter()
510 .map(|(task, predictions)| {
511 let mut invocations = BTreeSet::new();
512 let mut predictions = predictions
513 .into_iter()
514 .rev()
515 .filter(|prediction| invocations.insert(prediction.invocation.clone()))
516 .collect::<Vec<_>>();
517 predictions.reverse();
518 TaskActionManifest {
519 version: 1,
520 task,
521 predictions,
522 }
523 })
524 .collect::<Vec<_>>();
525 if tasks.iter().any(|task| !task.validate()) {
526 eyre::bail!("combined export predictions exceed task manifest limits");
527 }
528 validate_export_additions(&additions)?;
529 let mut closure = strict_closure(store, &actions)?;
530 let cas = LocalCas::new(store);
531 for digest in &additions.objects {
532 require_object(&cas, &mut closure, digest)?;
533 }
534 verify_pending(&mut closure.pending).wrap_err("cache closure is incomplete or corrupt")?;
538 let parent = archive
539 .parent()
540 .filter(|parent| !parent.as_os_str().is_empty())
541 .unwrap_or_else(|| Path::new("."));
542 std::fs::create_dir_all(parent)?;
543 let manifest = serde_json::to_vec(&ExportManifest {
544 version: if additions.attachments.is_empty() && additions.objects.is_empty() {
545 LEGACY_EXPORT_VERSION
546 } else {
547 EXPORT_VERSION
548 },
549 tasks,
550 actions: actions.iter().cloned().collect(),
551 attachments: additions.attachments.clone(),
552 objects: additions.objects.iter().cloned().collect(),
553 })?;
554 let members = closure
555 .objects
556 .iter()
557 .chain(closure.results.iter())
558 .collect::<Vec<_>>();
559 let bytes = match form {
560 ExportForm::Tar => write_tar_export(store, archive, parent, &manifest, &members)?,
561 ExportForm::Directory => {
562 write_directory_export(store, archive, parent, &manifest, &members)?
563 }
564 };
565 Ok(TransferOutcome {
566 actions: actions.len() as u64,
567 objects: closure.objects.len() as u64,
568 bytes,
569 })
570}
571
572fn write_tar_export(
574 store: &Path,
575 archive: &Path,
576 parent: &Path,
577 manifest: &[u8],
578 members: &[&PathBuf],
579) -> Result<u64> {
580 let temporary = tempfile::Builder::new()
581 .prefix(".mbx-export-")
582 .tempfile_in(parent)?;
583 let mut builder = tar::Builder::new(temporary.reopen()?);
584 append_bytes(&mut builder, Path::new(EXPORT_MANIFEST), manifest)?;
585 for path in members {
586 append_file(&mut builder, store, path)?;
587 }
588 builder.finish()?;
589 drop(builder);
590 temporary
591 .persist(archive)
592 .map_err(|error| error.error)
593 .wrap_err_with(|| format!("failed to publish {}", archive.display()))?;
594 Ok(std::fs::metadata(archive)?.len())
595}
596
597fn write_directory_export(
603 store: &Path,
604 destination: &Path,
605 parent: &Path,
606 manifest: &[u8],
607 members: &[&PathBuf],
608) -> Result<u64> {
609 let staging = tempfile::Builder::new()
610 .prefix(".mbx-export-")
611 .tempdir_in(parent)?;
612 std::fs::write(staging.path().join(EXPORT_MANIFEST), manifest)?;
613 let mut bytes = manifest.len() as u64;
614 for path in members {
615 let relative = path.strip_prefix(store)?;
616 let target = staging.path().join(relative);
617 std::fs::create_dir_all(target.parent().expect("export member has a parent"))?;
618 bytes += std::fs::copy(path, &target)?;
619 }
620 let staged = staging.keep();
621 let retired = match std::fs::symlink_metadata(destination) {
626 Ok(_) => {
627 let holder = tempfile::Builder::new()
628 .prefix(".mbx-export-retired-")
629 .tempdir_in(parent)?;
630 let moved = holder.path().join("bundle");
631 std::fs::rename(destination, &moved)
632 .wrap_err_with(|| format!("failed to replace {}", destination.display()))?;
633 Some((holder, moved))
634 }
635 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
636 Err(error) => {
637 return Err(error)
638 .wrap_err_with(|| format!("failed to replace {}", destination.display()));
639 }
640 };
641 if let Err(error) = std::fs::rename(&staged, destination) {
642 let _ = std::fs::remove_dir_all(&staged);
643 if let Some((holder, moved)) = retired
644 && std::fs::rename(&moved, destination).is_err()
645 {
646 let kept = holder.keep();
651 log::warn!(
652 "could not restore {} after a failed export; its previous contents are at {}",
653 destination.display(),
654 kept.join("bundle").display()
655 );
656 }
657 return Err(error).wrap_err_with(|| format!("failed to publish {}", destination.display()));
658 }
659 drop(retired);
661 Ok(bytes)
662}
663
664pub fn import_archive(store: &Path, archive: &Path) -> Result<TransferOutcome> {
666 Ok(import_archive_with_attachments(store, archive)?.transfer)
667}
668
669pub fn import_archive_with_attachments(store: &Path, archive: &Path) -> Result<ImportOutcome> {
671 let staged;
675 let _staging_lock;
676 let root = if archive.is_dir() {
677 _staging_lock = None;
678 validate_directory_bundle(archive)?;
679 archive
680 } else {
681 let staging_root = store.join(IMPORT_STAGING_DIR);
687 std::fs::create_dir_all(&staging_root)?;
688 staged = tempfile::Builder::new()
689 .prefix("import-")
690 .tempdir_in(&staging_root)?;
691 _staging_lock = Some(lock_import_staging(staged.path())?);
696 unpack_archive(archive, staged.path())?;
697 staged.path()
698 };
699 let bundle_bytes = archive.is_dir().then(|| tree_bytes(archive));
702 let manifest: ExportManifest =
703 serde_json::from_slice(&std::fs::read(root.join(EXPORT_MANIFEST))?)?;
704 let actions = manifest.actions.iter().cloned().collect::<BTreeSet<_>>();
705 let task_identities = manifest
706 .tasks
707 .iter()
708 .map(|task| task.task.as_str())
709 .collect::<BTreeSet<_>>();
710 if !matches!(manifest.version, LEGACY_EXPORT_VERSION | EXPORT_VERSION)
711 || (manifest.version == LEGACY_EXPORT_VERSION
712 && (!manifest.attachments.is_empty() || !manifest.objects.is_empty()))
713 || manifest.tasks.is_empty()
714 || actions.len() != manifest.actions.len()
715 || task_identities.len() != manifest.tasks.len()
716 || manifest.tasks.iter().any(|task| !task.validate())
717 || manifest
718 .actions
719 .iter()
720 .any(|action| action.algorithm != "blake3" || action.validate().is_err())
721 || manifest.tasks.iter().any(|task| {
722 task.predictions
723 .iter()
724 .any(|prediction| !actions.contains(&prediction.action))
725 })
726 || manifest
727 .attachments
728 .keys()
729 .any(|name| !valid_attachment_name(name))
730 || manifest.attachments.values().any(|digest| {
731 digest.algorithm != "blake3"
732 || digest.validate().is_err()
733 || !manifest.objects.contains(digest)
734 })
735 || manifest.objects.iter().collect::<BTreeSet<_>>().len() != manifest.objects.len()
736 || manifest
737 .objects
738 .iter()
739 .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
740 {
741 eyre::bail!("unsupported or invalid cache export manifest");
742 }
743 let mut closure =
744 strict_closure(root, &actions).wrap_err("cache export is incomplete or corrupt")?;
745 let staged_cas = LocalCas::new(root);
746 for digest in &manifest.objects {
747 require_object(&staged_cas, &mut closure, digest)
748 .wrap_err("cache export attachment is incomplete or corrupt")?;
749 }
750 verify_pending(&mut closure.pending).wrap_err("cache export is incomplete or corrupt")?;
751
752 let results = closure
760 .results
761 .iter()
762 .map(|path| {
763 let result: RemoteActionResult = serde_json::from_slice(&std::fs::read(path)?)
764 .wrap_err_with(|| format!("action result is invalid: {}", path.display()))?;
765 Ok(result)
766 })
767 .collect::<Result<Vec<_>>>()
768 .wrap_err("cache export is incomplete or corrupt")?;
769
770 let cas = LocalCas::new(store);
771 for path in &closure.objects {
772 let relative = path.strip_prefix(root)?;
773 let source = root.join(relative);
774 let digest = addressed_digest(root, &source, false)
775 .ok_or_else(|| eyre::eyre!("invalid cache object path {}", relative.display()))?;
776 cas.adopt_verified_file(&digest, &source)?;
780 }
781 let action_cache = mbx_cache_core::LocalActionCache::new(store);
782 let mut kept_existing = 0;
783 for result in &results {
784 if action_cache.store_unless_restorable(result, |existing| {
785 extend_result_closure(
786 &cas,
787 &mut Closure::default(),
788 &mut BTreeSet::new(),
789 existing,
790 false,
791 )
792 .is_ok()
793 })? == mbx_cache_core::ActionStoreOutcome::KeptExisting
794 {
795 kept_existing += 1;
796 }
797 }
798 if kept_existing > 0 {
799 log::debug!(
800 "cache import kept {kept_existing} intact existing results that differed from the bundle"
801 );
802 }
803 for task in manifest.tasks {
804 merge_imported_manifest(store, task)?;
805 }
806 let bytes = match bundle_bytes {
807 Some(bytes) => {
808 std::fs::remove_dir_all(archive)
812 .wrap_err_with(|| format!("failed to remove {}", archive.display()))?;
813 bytes
814 }
815 None => std::fs::metadata(archive)?.len(),
816 };
817 Ok(ImportOutcome {
818 transfer: TransferOutcome {
819 actions: actions.len() as u64,
820 objects: closure.objects.len() as u64,
821 bytes,
822 },
823 attachments: manifest.attachments,
824 })
825}
826
827fn validate_export_additions(additions: &ExportAdditions) -> Result<()> {
828 if additions
829 .attachments
830 .keys()
831 .any(|name| !valid_attachment_name(name))
832 || additions
833 .attachments
834 .values()
835 .any(|digest| !additions.objects.contains(digest))
836 || additions
837 .objects
838 .iter()
839 .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
840 {
841 eyre::bail!("invalid cache export attachment");
842 }
843 Ok(())
844}
845
846fn valid_attachment_name(name: &str) -> bool {
847 !name.is_empty()
848 && name.len() <= 128
849 && name
850 .bytes()
851 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
852}
853
854#[derive(Default)]
856struct Closure {
857 objects: BTreeSet<PathBuf>,
858 results: BTreeSet<PathBuf>,
859 pending: Vec<PendingObject>,
860}
861
862struct PendingObject {
864 digest: CacheDigest,
865 path: PathBuf,
866}
867
868fn strict_closure(store: &Path, actions: &BTreeSet<CacheDigest>) -> Result<Closure> {
869 let cas = LocalCas::new(store);
870 let action_cache = mbx_cache_core::LocalActionCache::new(store);
871 let mut closure = Closure::default();
872 let mut directories = BTreeSet::new();
873 for action in actions {
874 let result = action_cache
875 .find(action)?
876 .ok_or_else(|| eyre::eyre!("action result is missing for {}", action.hash))?;
877 closure.results.insert(action_cache.path_for(action)?);
878 extend_result_closure(&cas, &mut closure, &mut directories, &result, true)?;
879 }
880 Ok(closure)
881}
882
883fn extend_result_closure(
886 cas: &LocalCas,
887 closure: &mut Closure,
888 directories: &mut BTreeSet<CacheDigest>,
889 result: &RemoteActionResult,
890 verify_descriptors: bool,
891) -> Result<()> {
892 let parsed_object = |closure: &mut Closure, digest: &CacheDigest| {
893 if verify_descriptors {
894 require_parsed_object(cas, closure, digest)
895 } else {
896 require_object(cas, closure, digest)?;
897 cas.path_for(digest)
898 }
899 };
900 require_object(cas, closure, &result.action)?;
901 if let Some(metadata) = &result.metadata {
902 let path = parsed_object(closure, metadata)?;
903 let captured: CapturedOutput =
904 serde_json::from_slice(&std::fs::read(path)?).wrap_err("action metadata is invalid")?;
905 require_object(cas, closure, &captured.stdout)?;
906 require_object(cas, closure, &captured.stderr)?;
907 }
908 if let Some(root) = &result.output_root {
909 let mut nodes = vec![root.clone()];
910 while let Some(digest) = nodes.pop() {
911 if !directories.insert(digest.clone()) {
912 continue;
913 }
914 let path = parsed_object(closure, &digest)?;
915 let directory: CacheDirectory = serde_json::from_slice(&std::fs::read(path)?)
916 .wrap_err("output directory is invalid")?;
917 for file in directory.files {
918 require_object(cas, closure, &file.digest)?;
919 }
920 for child in directory.directories {
921 nodes.push(child.digest);
922 }
923 }
924 }
925 Ok(())
926}
927
928fn verify_pending(pending: &mut [PendingObject]) -> Result<()> {
936 if pending.is_empty() {
937 return Ok(());
938 }
939 pending.sort_by(|left, right| {
943 right
944 .digest
945 .size
946 .cmp(&left.digest.size)
947 .then_with(|| left.path.cmp(&right.path))
948 });
949 let workers = std::thread::available_parallelism()
950 .map(usize::from)
951 .unwrap_or(1)
952 .min(pending.len());
953 let next = AtomicUsize::new(0);
954 let failure: Mutex<Option<(PathBuf, eyre::Report)>> = Mutex::new(None);
957 let pending: &[PendingObject] = pending;
958 std::thread::scope(|scope| {
959 for _ in 0..workers {
960 scope.spawn(|| {
961 loop {
962 let index = next.fetch_add(1, Ordering::Relaxed);
963 let Some(entry) = pending.get(index) else {
964 break;
965 };
966 let error = match entry.digest.matches_file(&entry.path) {
967 Ok(true) => continue,
968 Ok(false) => eyre::eyre!(
969 "local CAS blob failed digest verification: {}",
970 entry.path.display()
971 ),
972 Err(error) => {
973 error.wrap_err(format!("failed to verify {}", entry.path.display()))
974 }
975 };
976 let mut failure = failure.lock().unwrap_or_else(|error| error.into_inner());
977 if failure.as_ref().is_none_or(|(path, _)| entry.path < *path) {
978 *failure = Some((entry.path.clone(), error));
979 }
980 }
981 });
982 }
983 });
984 match failure
985 .into_inner()
986 .unwrap_or_else(|error| error.into_inner())
987 {
988 Some((_, error)) => Err(error),
989 None => Ok(()),
990 }
991}
992
993#[derive(Deserialize)]
994struct CapturedOutput {
995 stdout: CacheDigest,
996 stderr: CacheDigest,
997}
998
999fn merge_imported_manifest(destination: &Path, mut imported: TaskActionManifest) -> Result<()> {
1000 let identity = imported.task.clone();
1001 let destination_path = task_manifest_path(destination, &identity);
1002 let lock_path = task_manifest_lock_path(destination, &identity);
1003 std::fs::create_dir_all(lock_path.parent().expect("task manifest lock has a parent"))?;
1004 let mut lock = fslock::LockFile::open(&lock_path)?;
1005 lock.lock()?;
1006 if let Ok(bytes) = std::fs::read(&destination_path)
1007 && let Ok(existing) = serde_json::from_slice::<TaskActionManifest>(&bytes)
1008 && existing.task == identity
1009 && existing.validate()
1010 {
1011 let existing_predictions: BTreeMap<_, _> = existing
1012 .predictions
1013 .iter()
1014 .cloned()
1015 .map(|prediction| (prediction.invocation.clone(), prediction))
1016 .collect();
1017 let updates = imported
1020 .predictions
1021 .into_iter()
1022 .map(|prediction| {
1023 existing_predictions
1024 .get(&prediction.invocation)
1025 .cloned()
1026 .unwrap_or(prediction)
1027 })
1028 .collect();
1029 imported.predictions =
1030 merge_task_action_predictions(existing.predictions, updates, &BTreeSet::new())?;
1031 }
1032 write_atomic(&destination_path, &serde_json::to_vec(&imported)?)
1033}
1034
1035fn require_object(cas: &LocalCas, closure: &mut Closure, digest: &CacheDigest) -> Result<()> {
1042 let expected = cas.path_for(digest)?;
1043 if !closure.objects.insert(expected.clone()) {
1044 return Ok(());
1045 }
1046 let metadata = match std::fs::metadata(&expected) {
1047 Ok(metadata) => metadata,
1048 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1049 eyre::bail!("cache object is missing for {}", digest.hash)
1050 }
1051 Err(error) => {
1052 return Err(error).wrap_err_with(|| format!("failed to read {}", expected.display()));
1053 }
1054 };
1055 if metadata.len() != digest.size {
1056 eyre::bail!(
1057 "local CAS blob failed digest verification: {}",
1058 expected.display()
1059 );
1060 }
1061 closure.pending.push(PendingObject {
1062 digest: digest.clone(),
1063 path: expected,
1064 });
1065 Ok(())
1066}
1067
1068fn require_parsed_object(
1075 cas: &LocalCas,
1076 closure: &mut Closure,
1077 digest: &CacheDigest,
1078) -> Result<PathBuf> {
1079 let path = cas
1080 .find(digest)?
1081 .ok_or_else(|| eyre::eyre!("cache object is missing for {}", digest.hash))?;
1082 closure.objects.insert(path.clone());
1083 Ok(path)
1084}
1085
1086fn task_manifest_path(store: &Path, identity: &str) -> PathBuf {
1087 store
1088 .join("task-manifests/v1")
1089 .join(format!("{identity}.json"))
1090}
1091
1092fn task_manifest_lock_path(store: &Path, identity: &str) -> PathBuf {
1093 store
1094 .join("task-manifests/v1/locks")
1095 .join(format!("{identity}.lock"))
1096}
1097
1098fn append_file(builder: &mut tar::Builder<std::fs::File>, root: &Path, path: &Path) -> Result<()> {
1099 let name = path.strip_prefix(root)?;
1100 builder.append_path_with_name(path, name)?;
1101 Ok(())
1102}
1103
1104fn append_bytes(
1105 builder: &mut tar::Builder<std::fs::File>,
1106 name: &Path,
1107 bytes: &[u8],
1108) -> Result<()> {
1109 let mut header = tar::Header::new_gnu();
1110 header.set_size(bytes.len() as u64);
1111 header.set_mode(0o644);
1112 header.set_cksum();
1113 builder.append_data(&mut header, name, bytes)?;
1114 Ok(())
1115}
1116
1117fn unpack_archive(archive: &Path, staging: &Path) -> Result<()> {
1119 let file = std::fs::File::open(archive)
1120 .wrap_err_with(|| format!("failed to open {}", archive.display()))?;
1121 let mut bundle = tar::Archive::new(file);
1122 let mut seen = BTreeSet::new();
1123 for entry in bundle.entries()? {
1124 let mut entry = entry?;
1125 let entry_type = entry.header().entry_type();
1126 if !entry_type.is_file() && !entry_type.is_gnu_sparse() {
1127 eyre::bail!("cache export contains a non-file entry");
1128 }
1129 let path = entry.path()?.into_owned();
1130 validate_archive_path(&path)?;
1131 if !seen.insert(path.clone()) {
1132 eyre::bail!("cache export contains duplicate entry {}", path.display());
1133 }
1134 let destination = staging.join(&path);
1135 std::fs::create_dir_all(destination.parent().expect("entry has a parent"))?;
1136 entry.unpack(&destination)?;
1140 }
1141 Ok(())
1142}
1143
1144fn validate_directory_bundle(root: &Path) -> Result<()> {
1152 let mut pending = vec![root.to_path_buf()];
1153 while let Some(directory) = pending.pop() {
1154 for entry in std::fs::read_dir(&directory)
1155 .wrap_err_with(|| format!("failed to read {}", directory.display()))?
1156 {
1157 let entry = entry?;
1158 let metadata = entry.metadata()?;
1159 let path = entry.path();
1160 if metadata.is_dir() {
1161 pending.push(path);
1162 continue;
1163 }
1164 if !metadata.is_file() {
1165 eyre::bail!("cache export contains a non-file entry");
1166 }
1167 validate_archive_path(path.strip_prefix(root)?)?;
1168 reject_linked_file(&metadata, &path)?;
1169 }
1170 }
1171 Ok(())
1172}
1173
1174#[cfg(unix)]
1181fn reject_linked_file(metadata: &std::fs::Metadata, path: &Path) -> Result<()> {
1182 use std::os::unix::fs::MetadataExt as _;
1183
1184 if metadata.nlink() > 1 {
1185 eyre::bail!("cache export contains a hard link at {}", path.display());
1186 }
1187 Ok(())
1188}
1189
1190#[cfg(not(unix))]
1196fn reject_linked_file(_metadata: &std::fs::Metadata, _path: &Path) -> Result<()> {
1197 Ok(())
1198}
1199
1200fn validate_archive_path(path: &Path) -> Result<()> {
1201 if path.as_os_str().is_empty()
1202 || path.is_absolute()
1203 || path
1204 .components()
1205 .any(|component| !matches!(component, Component::Normal(_)))
1206 {
1207 eyre::bail!("cache export contains unsafe path {}", path.display());
1208 }
1209 let parts = path
1214 .components()
1215 .map(|component| component.as_os_str().to_string_lossy().into_owned())
1216 .collect::<Vec<_>>();
1217 let expected = match parts.as_slice() {
1218 [name] => name == EXPORT_MANIFEST,
1219 [root, version, _rest @ ..] => {
1220 parts.len() > 2 && (root == "cas" || root == "action-results") && version == "v1"
1221 }
1222 [] => false,
1223 };
1224 if !expected {
1225 eyre::bail!("cache export contains unexpected path {}", path.display());
1226 }
1227 Ok(())
1228}
1229
1230#[derive(Debug, Clone, Serialize, Deserialize)]
1237#[serde(deny_unknown_fields)]
1238struct CheckoutRecord {
1239 version: u8,
1240 workspace_root: PathBuf,
1241 target_dir: PathBuf,
1242 updated_secs: u64,
1243}
1244
1245pub fn stats(store: &Path) -> Result<StoreStats> {
1247 let objects = walk_files(&store.join(CAS_DIR))?;
1248 let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
1249 let checkouts = scan_checkouts(store)?;
1250 Ok(StoreStats {
1251 objects: objects.len() as u64,
1252 object_bytes: objects.iter().map(|entry| entry.size).sum(),
1253 action_results: results.len() as u64,
1254 action_result_bytes: results.iter().map(|entry| entry.size).sum(),
1255 live_checkouts: checkouts.live_records,
1256 stale_checkouts: checkouts.stale_records.len() as u64,
1257 })
1258}
1259
1260fn checkout_target_bytes(sizes: &mut BTreeMap<PathBuf, u64>, record: &CheckoutRecord) -> u64 {
1267 if record.target_dir == record.workspace_root {
1268 return 0;
1269 }
1270 cached_tree_bytes(sizes, &record.target_dir)
1271}
1272
1273pub fn projects(store: &Path) -> Result<Vec<ProjectUsage>> {
1279 project_usage(store, true)
1280}
1281
1282pub fn live_project_cache_bytes(store: &Path) -> Result<Vec<u64>> {
1288 Ok(project_usage(store, false)?
1289 .into_iter()
1290 .filter(|project| project.live)
1291 .map(|project| project.action_bytes)
1292 .collect())
1293}
1294
1295fn project_usage(store: &Path, size_targets: bool) -> Result<Vec<ProjectUsage>> {
1296 let mut projects: BTreeMap<PathBuf, (BTreeSet<String>, bool, u64)> = BTreeMap::new();
1297 let mut target_sizes = BTreeMap::new();
1298 let root = store.join(CHECKOUTS_DIR);
1299 for identity_entry in read_dir_or_empty(&root)? {
1300 let identity = identity_entry.file_name().to_string_lossy().into_owned();
1301 if !is_task_identity(&identity) || !identity_entry.file_type()?.is_dir() {
1302 continue;
1303 }
1304 for entry in walk_files(&identity_entry.path())? {
1305 let Some(record) = read_checkout_record(&entry.path) else {
1306 continue;
1307 };
1308 let project = projects.entry(record.workspace_root.clone()).or_default();
1309 if claim_is_live(store, &record) {
1310 project.0.insert(identity.clone());
1311 project.1 = true;
1312 }
1313 if size_targets {
1314 project.2 = project
1315 .2
1316 .max(checkout_target_bytes(&mut target_sizes, &record));
1317 }
1318 }
1319 }
1320 let live = projects
1321 .values()
1322 .flat_map(|(identities, _, _)| identities.iter().cloned())
1323 .collect::<BTreeSet<_>>();
1324 let mut reachability = Reachability::new(store);
1325 resolve_identities(&reachability, &live);
1326 let mut usages = Vec::new();
1327 for (workspace_root, (identities, live, target_bytes)) in projects {
1328 let action_bytes = reachability.bytes(&identities);
1329 usages.push(ProjectUsage {
1330 workspace_root,
1331 identities: identities.len() as u64,
1332 action_bytes,
1333 target_bytes,
1334 live,
1335 });
1336 }
1337 usages.sort_by(|left, right| {
1338 right
1339 .action_bytes
1340 .saturating_add(right.target_bytes)
1341 .cmp(&left.action_bytes.saturating_add(left.target_bytes))
1342 .then_with(|| left.workspace_root.cmp(&right.workspace_root))
1343 });
1344 Ok(usages)
1345}
1346
1347fn resolve_identities(reachability: &Reachability, identities: &BTreeSet<String>) {
1355 const MAX_WORKERS: usize = 8;
1356 let identities = identities.iter().collect::<Vec<_>>();
1357 let workers = std::thread::available_parallelism()
1358 .map(usize::from)
1359 .unwrap_or(1)
1360 .min(MAX_WORKERS)
1361 .min(identities.len());
1362 let next = AtomicUsize::new(0);
1363 std::thread::scope(|scope| {
1364 for _ in 0..workers {
1365 scope.spawn(|| {
1366 while let Some(identity) = identities.get(next.fetch_add(1, Ordering::Relaxed)) {
1367 let actions = task_manifest_actions(&reachability.store, identity)
1371 .unwrap_or_else(|error| {
1372 log::debug!("could not read the manifest for {identity}: {error}");
1373 Vec::new()
1374 });
1375 reachability.record(identity, &actions);
1376 }
1377 });
1378 }
1379 });
1380}
1381
1382struct Reachability {
1395 store: PathBuf,
1396 cas: LocalCas,
1397 action_cache: mbx_cache_core::LocalActionCache,
1398 paths: Mutex<PathIndex>,
1400 identities: Mutex<HashMap<String, ActionPaths>>,
1401 actions: Mutex<HashMap<CacheDigest, Arc<[usize]>>>,
1402 directories: Mutex<HashMap<CacheDigest, Option<DirectoryPaths>>>,
1404 counted: Vec<usize>,
1406 workspaces: usize,
1407}
1408
1409#[derive(Default)]
1410struct PathIndex {
1411 indices: HashMap<PathBuf, usize>,
1412 sizes: Vec<u64>,
1413}
1414
1415type ActionPaths = Arc<[Arc<[usize]>]>;
1417
1418type DirectoryPaths = (Arc<[CacheDigest]>, Arc<[usize]>);
1419
1420fn locked<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
1423 mutex.lock().unwrap_or_else(|error| error.into_inner())
1424}
1425
1426impl Reachability {
1427 fn new(store: &Path) -> Self {
1428 Self {
1429 store: store.to_path_buf(),
1430 cas: LocalCas::new(store),
1431 action_cache: mbx_cache_core::LocalActionCache::new(store),
1432 paths: Mutex::default(),
1433 identities: Mutex::default(),
1434 actions: Mutex::default(),
1435 directories: Mutex::default(),
1436 counted: Vec::new(),
1437 workspaces: 0,
1438 }
1439 }
1440
1441 fn bytes(&mut self, identities: &BTreeSet<String>) -> u64 {
1445 let reachable = {
1446 let recorded = locked(&self.identities);
1447 identities
1448 .iter()
1449 .filter_map(|identity| recorded.get(identity).cloned())
1450 .collect::<Vec<_>>()
1451 };
1452 let sizes = std::mem::take(&mut locked(&self.paths).sizes);
1453 self.workspaces += 1;
1454 self.counted.resize(sizes.len(), 0);
1455 let mut bytes = 0_u64;
1456 for index in reachable
1457 .iter()
1458 .flat_map(|actions| actions.iter())
1459 .flat_map(|paths| paths.iter())
1460 {
1461 if self.counted[*index] != self.workspaces {
1462 self.counted[*index] = self.workspaces;
1463 bytes = bytes.saturating_add(sizes[*index]);
1464 }
1465 }
1466 locked(&self.paths).sizes = sizes;
1467 bytes
1468 }
1469
1470 fn record(&self, identity: &str, actions: &[CacheDigest]) {
1475 let actions = actions
1476 .iter()
1477 .map(|action| self.action(action))
1478 .collect::<Arc<[_]>>();
1479 locked(&self.identities).insert(identity.to_string(), actions);
1480 }
1481
1482 fn action(&self, action: &CacheDigest) -> Arc<[usize]> {
1483 if let Some(paths) = locked(&self.actions).get(action) {
1484 return paths.clone();
1485 }
1486 let mut paths = Vec::new();
1487 if let Ok(path) = self.action_cache.path_for(action) {
1488 paths.push(self.index(path));
1489 }
1490 if let Some(result) = read_action_result(&self.store, action) {
1491 self.push_digest(&mut paths, &result.action);
1492 if let Some(metadata) = &result.metadata {
1493 self.push_digest(&mut paths, metadata);
1494 if let Some(rustc) = read_rustc_metadata(&self.cas, metadata) {
1495 self.push_digest(&mut paths, &rustc.stdout);
1496 self.push_digest(&mut paths, &rustc.stderr);
1497 }
1498 }
1499 if let Some(output_root) = &result.output_root {
1500 self.push_digest(&mut paths, output_root);
1501 let mut pending = vec![output_root.clone()];
1502 let mut visited = HashSet::new();
1503 while let Some(digest) = pending.pop() {
1504 if !visited.insert(digest.clone()) {
1505 continue;
1506 }
1507 let Some((children, named)) = self.directory(&digest) else {
1508 continue;
1509 };
1510 paths.extend(named.iter().copied());
1511 pending.extend(children.iter().cloned());
1512 }
1513 }
1514 }
1515 paths.sort_unstable();
1516 paths.dedup();
1517 let paths = Arc::<[usize]>::from(paths);
1518 locked(&self.actions).insert(action.clone(), paths.clone());
1519 paths
1520 }
1521
1522 fn directory(&self, digest: &CacheDigest) -> Option<DirectoryPaths> {
1524 if let Some(entry) = locked(&self.directories).get(digest) {
1525 return entry.clone();
1526 }
1527 let entry = read_directory(&self.cas, digest).map(|directory| {
1528 let mut paths = Vec::new();
1529 for file in &directory.files {
1530 self.push_digest(&mut paths, &file.digest);
1531 }
1532 for child in &directory.directories {
1533 self.push_digest(&mut paths, &child.digest);
1534 }
1535 let children = directory
1536 .directories
1537 .into_iter()
1538 .map(|child| child.digest)
1539 .collect::<Arc<[_]>>();
1540 (children, Arc::<[usize]>::from(paths))
1541 });
1542 locked(&self.directories).insert(digest.clone(), entry.clone());
1543 entry
1544 }
1545
1546 fn push_digest(&self, paths: &mut Vec<usize>, digest: &CacheDigest) {
1547 if let Ok(path) = self.cas.path_for(digest) {
1548 paths.push(self.index(path));
1549 }
1550 }
1551
1552 fn index(&self, path: PathBuf) -> usize {
1553 if let Some(index) = locked(&self.paths).indices.get(&path) {
1554 return *index;
1555 }
1556 let size = std::fs::metadata(&path).map_or(0, |metadata| metadata.len());
1559 let mut paths = locked(&self.paths);
1560 if let Some(index) = paths.indices.get(&path) {
1561 return *index;
1562 }
1563 let index = paths.sizes.len();
1564 paths.sizes.push(size);
1565 paths.indices.insert(path, index);
1566 index
1567 }
1568}
1569
1570pub fn largest(store: &Path, limit: usize) -> Result<Vec<LargestEntry>> {
1572 let mut entries = Vec::new();
1573 for (kind, root, action_result) in [
1574 ("object", store.join(CAS_DIR), false),
1575 ("action result", store.join(ACTION_RESULTS_DIR), true),
1576 ] {
1577 entries.extend(
1578 walk_files(&root)?
1579 .into_iter()
1580 .filter(|entry| addressed_digest(store, &entry.path, action_result).is_some())
1581 .map(|entry| LargestEntry {
1582 kind,
1583 path: entry.path,
1584 bytes: entry.size,
1585 }),
1586 );
1587 }
1588 entries.sort_by(|left, right| {
1589 right
1590 .bytes
1591 .cmp(&left.bytes)
1592 .then_with(|| left.path.cmp(&right.path))
1593 });
1594 entries.truncate(limit);
1595 Ok(entries)
1596}
1597
1598pub fn verify(store: &Path) -> Result<VerifyOutcome> {
1600 let cas = LocalCas::new(store);
1601 let action_cache = mbx_cache_core::LocalActionCache::new(store);
1602 let virtually_removed = HashSet::new();
1605 let mut outcome = VerifyOutcome::default();
1606 for entry in walk_files(&store.join(CAS_DIR))? {
1607 let Some(digest) = addressed_digest(store, &entry.path, false) else {
1608 continue;
1609 };
1610 outcome.checked_objects += 1;
1611 if !matches!(cas.find(&digest), Ok(Some(_))) {
1612 outcome.problems.push(entry.path);
1613 }
1614 }
1615 for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
1616 let Some(digest) = addressed_digest(store, &entry.path, true) else {
1617 continue;
1618 };
1619 outcome.checked_action_results += 1;
1620 if !matches!(action_cache.find(&digest), Ok(Some(_)))
1621 || action_result_is_dangling(&cas, &entry.path, &virtually_removed)?
1622 {
1623 outcome.problems.push(entry.path);
1624 }
1625 }
1626 outcome.problems.sort();
1627 Ok(outcome)
1628}
1629
1630pub fn remove_project(store: &Path, workspace_root: &Path) -> Result<RemoveProjectOutcome> {
1632 let mut outcome = RemoveProjectOutcome::default();
1633 for entry in walk_files(&store.join(CHECKOUTS_DIR))? {
1634 if read_checkout_record(&entry.path)
1635 .is_some_and(|record| record.workspace_root == workspace_root)
1636 {
1637 std::fs::remove_file(&entry.path)?;
1638 outcome.removed_checkout_records += 1;
1639 if let Some(parent) = entry.path.parent() {
1640 let _ = std::fs::remove_dir(parent);
1641 }
1642 }
1643 }
1644 match std::fs::remove_file(latest_receipt_path(store, workspace_root)) {
1645 Ok(()) => {}
1646 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1647 Err(error) => return Err(error.into()),
1648 }
1649 Ok(outcome)
1650}
1651
1652pub fn record_checkout(
1664 store: &Path,
1665 identity: &str,
1666 workspace_root: &Path,
1667 target_dir: &Path,
1668) -> Result<()> {
1669 let record = CheckoutRecord {
1670 version: CHECKOUT_RECORD_VERSION,
1671 workspace_root: workspace_root.to_path_buf(),
1672 target_dir: target_dir.to_path_buf(),
1673 updated_secs: SystemTime::now()
1674 .duration_since(UNIX_EPOCH)
1675 .map(|since| since.as_secs())
1676 .unwrap_or_default(),
1677 };
1678 let mut contents = serde_json::to_vec(&record)?;
1679 contents.push(b'\n');
1680 write_advisory(
1683 &checkout_record_path(store, identity, workspace_root),
1684 &contents,
1685 )
1686}
1687
1688fn checkout_record_path(store: &Path, identity: &str, workspace_root: &Path) -> PathBuf {
1689 let key = CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash;
1693 store
1694 .join(CHECKOUTS_DIR)
1695 .join(identity)
1696 .join(format!("{key}.json"))
1697}
1698
1699pub fn gc(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
1709 gc_with_mode(store, max_bytes, false)
1710}
1711
1712pub fn gc_dry_run(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
1714 gc_with_mode(store, max_bytes, true)
1715}
1716
1717fn gc_with_mode(store: &Path, max_bytes: u64, dry_run: bool) -> Result<GcOutcome> {
1718 let mut objects = walk_files(&store.join(CAS_DIR))?;
1719 let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
1720 let mut live_bytes = objects
1721 .iter()
1722 .chain(results.iter())
1723 .map(|entry| entry.size)
1724 .sum::<u64>();
1725
1726 let mut outcome = GcOutcome::default();
1727
1728 let checkouts = scan_checkouts(store)?;
1731 for path in &checkouts.stale_records {
1732 if dry_run || matches!(remove(path)?, Removal::Removed) {
1733 outcome.removed_checkout_records += 1;
1734 }
1735 if !dry_run && let Some(parent) = path.parent() {
1738 let _ = std::fs::remove_dir(parent);
1739 }
1740 }
1741
1742 let mut virtually_removed = HashSet::new();
1743 if live_bytes > max_bytes {
1744 let mut rooted = rooted_objects(store, &checkouts.live_identities)?;
1748 rooted.extend(rooted_action_objects(
1755 store,
1756 grouped_receipt_actions(store)?,
1757 ));
1758 objects.sort_by_cached_key(|entry| (rooted.contains(&entry.path), entry.used));
1762 let evicted = evict_objects(&objects, &rooted, live_bytes, max_bytes, |path| {
1763 if dry_run {
1764 virtually_removed.insert(path.to_path_buf());
1765 Ok(Removal::Removed)
1766 } else {
1767 remove(path)
1768 }
1769 })?;
1770 live_bytes = evicted.remaining_bytes;
1771 outcome.removed_objects += evicted.removed_objects;
1772 outcome.removed_bytes += evicted.removed_bytes;
1773 }
1774
1775 let cas = LocalCas::new(store);
1780 for entry in &results {
1781 if action_result_is_dangling(&cas, &entry.path, &virtually_removed)? {
1782 match if dry_run {
1783 Removal::Removed
1784 } else {
1785 remove(&entry.path)?
1786 } {
1787 Removal::Removed => {
1788 live_bytes = live_bytes.saturating_sub(entry.size);
1789 outcome.removed_action_results += 1;
1790 outcome.removed_bytes += entry.size;
1791 }
1792 Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
1793 Removal::Blocked => {}
1794 }
1795 }
1796 }
1797
1798 let sessions = prune_sessions(store, dry_run)?;
1799 outcome.removed_session_streams += sessions.removed_streams;
1800 outcome.removed_bytes += sessions.removed_bytes;
1801 prune_import_staging(store, dry_run);
1802
1803 outcome.remaining_bytes = live_bytes;
1804 Ok(outcome)
1805}
1806
1807fn prune_import_staging(store: &Path, dry_run: bool) {
1819 if dry_run {
1820 return;
1821 }
1822 let root = store.join(IMPORT_STAGING_DIR);
1823 let Ok(entries) = read_dir_or_empty(&root) else {
1824 return;
1825 };
1826 for entry in entries {
1827 let Ok(metadata) = entry.metadata() else {
1828 continue;
1829 };
1830 let abandoned = metadata
1831 .modified()
1832 .ok()
1833 .and_then(|modified| SystemTime::now().duration_since(modified).ok())
1834 .is_some_and(|age| age >= IMPORT_STAGING_RETENTION);
1835 if !abandoned {
1836 continue;
1837 }
1838 let path = entry.path();
1839 if !metadata.is_dir() {
1840 if path
1843 .extension()
1844 .is_some_and(|extension| extension == "lock")
1845 && !claimed_staging_path(&path).exists()
1846 {
1847 let _ = std::fs::remove_file(&path);
1848 }
1849 continue;
1850 }
1851 let lock_path = import_staging_lock_path(&path);
1857 let Ok(mut lock) = fslock::LockFile::open(&lock_path) else {
1858 continue;
1859 };
1860 if !matches!(lock.try_lock(), Ok(true)) {
1861 continue;
1862 }
1863 if let Err(error) = std::fs::remove_dir_all(&path) {
1864 log::debug!(
1865 "could not remove abandoned import staging {}: {error}",
1866 path.display()
1867 );
1868 continue;
1869 }
1870 drop(lock);
1871 let _ = std::fs::remove_file(&lock_path);
1872 }
1873}
1874
1875fn claimed_staging_path(lock: &Path) -> PathBuf {
1877 lock.with_extension("")
1878}
1879
1880fn import_staging_lock_path(staging: &Path) -> PathBuf {
1885 let mut name = staging.as_os_str().to_os_string();
1886 name.push(".lock");
1887 PathBuf::from(name)
1888}
1889
1890struct StagingClaim {
1896 lock: Option<fslock::LockFile>,
1897 path: PathBuf,
1898}
1899
1900impl Drop for StagingClaim {
1901 fn drop(&mut self) {
1902 drop(self.lock.take());
1905 let _ = std::fs::remove_file(&self.path);
1906 }
1907}
1908
1909fn lock_import_staging(staging: &Path) -> Result<StagingClaim> {
1911 let path = import_staging_lock_path(staging);
1912 let mut lock = fslock::LockFile::open(&path)
1913 .wrap_err_with(|| format!("failed to open {}", path.display()))?;
1914 lock.lock()?;
1915 Ok(StagingClaim {
1916 lock: Some(lock),
1917 path,
1918 })
1919}
1920
1921#[derive(Debug, Default, PartialEq, Eq)]
1922struct SessionPrune {
1923 removed_streams: u64,
1924 removed_bytes: u64,
1925}
1926
1927fn prune_sessions(store: &Path, dry_run: bool) -> Result<SessionPrune> {
1928 let mut prune = SessionPrune::default();
1929 let ids = events::session_ids(store);
1930 let surplus = ids.len().saturating_sub(MAX_SESSIONS);
1931 for (index, id) in ids.iter().enumerate() {
1932 let paths = events::session_paths(store, id);
1933 let metadata = match std::fs::metadata(&paths.events) {
1934 Ok(metadata) => metadata,
1935 Err(_) => continue,
1936 };
1937 let stale = metadata
1938 .modified()
1939 .ok()
1940 .and_then(|at| SystemTime::now().duration_since(at).ok())
1941 .is_some_and(|age| age > SESSION_RETENTION);
1942 if index >= surplus && !stale {
1943 continue;
1944 }
1945 if events::session_is_live(store, id) {
1946 continue;
1947 }
1948 if dry_run {
1949 prune.removed_streams += 1;
1950 prune.removed_bytes += metadata.len();
1951 continue;
1952 }
1953 if matches!(remove(&paths.events)?, Removal::Removed) {
1954 prune.removed_streams += 1;
1955 prune.removed_bytes += metadata.len();
1956 let _ = std::fs::remove_file(&paths.lock);
1957 }
1958 }
1959 if !dry_run {
1960 for lock in events::orphaned_locks(store) {
1961 let _ = std::fs::remove_file(lock);
1962 }
1963 }
1964 Ok(prune)
1965}
1966
1967pub fn sweep_if_due(store: &Path, max_bytes: u64, interval: Duration) -> Result<Option<GcOutcome>> {
1973 if !claim_sweep(store, interval)? {
1974 return Ok(None);
1975 }
1976 gc(store, max_bytes).map(Some)
1977}
1978
1979pub fn claim_sweep(store: &Path, interval: Duration) -> Result<bool> {
1985 let lock_path = store.join(SWEEP_LOCK);
1986 std::fs::create_dir_all(lock_path.parent().expect("sweep lock has a parent"))?;
1987 let mut lock = fslock::LockFile::open(&lock_path)?;
1988 lock.lock()?;
1989
1990 let stamp = store.join(SWEEP_STAMP);
1991 if sweep_stamp_is_fresh(&stamp, interval) {
1992 return Ok(false);
1993 }
1994 let stamp = std::fs::OpenOptions::new()
1995 .create(true)
1996 .write(true)
1997 .truncate(false)
1998 .open(&stamp)?;
1999 stamp.set_modified(SystemTime::now())?;
2000 Ok(true)
2001}
2002
2003pub fn sweep_is_due(store: &Path, interval: Duration) -> bool {
2011 !sweep_stamp_is_fresh(&store.join(SWEEP_STAMP), interval)
2012}
2013
2014fn sweep_stamp_is_fresh(stamp: &Path, interval: Duration) -> bool {
2015 std::fs::metadata(stamp)
2016 .and_then(|metadata| metadata.modified())
2017 .ok()
2018 .and_then(|modified| modified.elapsed().ok())
2019 .is_some_and(|since| since < interval)
2020}
2021
2022struct CheckoutScan {
2024 live_identities: BTreeSet<String>,
2025 live_records: u64,
2026 stale_records: Vec<PathBuf>,
2027}
2028
2029fn scan_checkouts(store: &Path) -> Result<CheckoutScan> {
2030 let root = store.join(CHECKOUTS_DIR);
2031 let mut scan = CheckoutScan {
2032 live_identities: BTreeSet::new(),
2033 live_records: 0,
2034 stale_records: Vec::new(),
2035 };
2036 let listing = match std::fs::read_dir(&root) {
2037 Ok(listing) => listing,
2038 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(scan),
2039 Err(error) => {
2040 return Err(error).wrap_err_with(|| format!("failed to read {}", root.display()));
2041 }
2042 };
2043 for entry in listing {
2044 let entry = entry?;
2045 let name = entry.file_name().to_string_lossy().into_owned();
2046 if !is_task_identity(&name) || !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
2052 continue;
2053 }
2054 for record in walk_files(&entry.path())? {
2055 match read_checkout_record(&record.path) {
2056 Some(checkout) if claim_is_live(store, &checkout) => {
2057 scan.live_identities.insert(name.clone());
2058 scan.live_records += 1;
2059 }
2060 Some(_) => scan.stale_records.push(record.path),
2061 None => {}
2064 }
2065 }
2066 }
2067 Ok(scan)
2068}
2069
2070fn read_checkout_record(path: &Path) -> Option<CheckoutRecord> {
2071 let bytes = std::fs::read(path).ok()?;
2072 let record = serde_json::from_slice::<CheckoutRecord>(&bytes).ok()?;
2073 (record.version == CHECKOUT_RECORD_VERSION).then_some(record)
2074}
2075
2076fn grouped_receipt_actions(store: &Path) -> Result<BTreeSet<CacheDigest>> {
2078 let root = store.join(BUILD_RECEIPTS_DIR).join("groups");
2079 Ok(walk_files(&root)?
2080 .into_iter()
2081 .filter_map(|entry| read_build_receipt(&entry.path))
2082 .flat_map(|receipt| receipt.predictions.into_iter())
2083 .map(|prediction| prediction.action)
2084 .collect())
2085}
2086
2087fn claim_is_live(store: &Path, record: &CheckoutRecord) -> bool {
2090 checkout_is_live_on(store, &record.workspace_root) && !claim_has_expired(record)
2091}
2092
2093fn claim_has_expired(record: &CheckoutRecord) -> bool {
2094 let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
2095 return false;
2096 };
2097 now.as_secs()
2098 .saturating_sub(record.updated_secs)
2099 .gt(&CHECKOUT_RETENTION.as_secs())
2100}
2101
2102pub fn checkout_is_live_on(store: &Path, workspace_root: &Path) -> bool {
2115 if !matches!(workspace_root.try_exists(), Ok(false)) {
2116 return true;
2117 }
2118
2119 let Ok(store_metadata) = std::fs::metadata(store) else {
2120 return true;
2121 };
2122 for ancestor in workspace_root.ancestors().skip(1) {
2123 match std::fs::metadata(ancestor) {
2124 Ok(ancestor_metadata) => {
2125 return !same_filesystem(store, &store_metadata, ancestor, &ancestor_metadata);
2126 }
2127 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2128 Err(_) => return true,
2129 }
2130 }
2131 true
2132}
2133
2134pub fn checkout_is_live(workspace_root: &Path) -> bool {
2140 let Some(parent) = workspace_root.parent() else {
2141 return true;
2142 };
2143 checkout_is_live_on(parent, workspace_root)
2144}
2145
2146pub fn same_filesystem_paths(a: &Path, b: &Path) -> bool {
2152 let (Ok(a_metadata), Ok(b_metadata)) = (std::fs::metadata(a), std::fs::metadata(b)) else {
2153 return false;
2154 };
2155 same_filesystem(a, &a_metadata, b, &b_metadata)
2156}
2157
2158#[cfg(unix)]
2159fn same_filesystem(
2160 _a_path: &Path,
2161 a: &std::fs::Metadata,
2162 _b_path: &Path,
2163 b: &std::fs::Metadata,
2164) -> bool {
2165 use std::os::unix::fs::MetadataExt as _;
2166 a.dev() == b.dev()
2167}
2168
2169#[cfg(windows)]
2170fn same_filesystem(
2171 a_path: &Path,
2172 _a: &std::fs::Metadata,
2173 b_path: &Path,
2174 _b: &std::fs::Metadata,
2175) -> bool {
2176 windows_volume_name(a_path).is_some_and(|volume| windows_volume_name(b_path) == Some(volume))
2177}
2178
2179#[cfg(windows)]
2180fn windows_volume_name(path: &Path) -> Option<Vec<u16>> {
2181 use std::os::windows::ffi::OsStrExt as _;
2182 use windows_sys::Win32::Storage::FileSystem::{
2183 GetVolumeNameForVolumeMountPointW, GetVolumePathNameW,
2184 };
2185
2186 let path = path
2187 .as_os_str()
2188 .encode_wide()
2189 .chain(Some(0))
2190 .collect::<Vec<_>>();
2191 let mut mount = vec![0_u16; 261];
2195 if unsafe { GetVolumePathNameW(path.as_ptr(), mount.as_mut_ptr(), mount.len() as u32) } == 0 {
2198 return None;
2199 }
2200 let mut volume = vec![0_u16; 261];
2201 if unsafe {
2204 GetVolumeNameForVolumeMountPointW(mount.as_ptr(), volume.as_mut_ptr(), volume.len() as u32)
2205 } == 0
2206 {
2207 return None;
2208 }
2209 volume.truncate(volume.iter().position(|unit| *unit == 0)?);
2210 Some(volume)
2211}
2212
2213#[cfg(not(any(unix, windows)))]
2214fn same_filesystem(
2215 _a_path: &Path,
2216 _a: &std::fs::Metadata,
2217 _b_path: &Path,
2218 _b: &std::fs::Metadata,
2219) -> bool {
2220 false
2221}
2222
2223fn rooted_objects(store: &Path, identities: &BTreeSet<String>) -> Result<HashSet<PathBuf>> {
2231 let mut actions = BTreeSet::new();
2232 for identity in identities {
2233 let manifest_actions = match task_manifest_actions(store, identity) {
2236 Ok(actions) => actions,
2237 Err(error) => {
2238 log::debug!("could not read the manifest for {identity}: {error}");
2239 continue;
2240 }
2241 };
2242 actions.extend(manifest_actions);
2243 }
2244 Ok(rooted_action_objects(store, actions))
2245}
2246
2247fn rooted_action_objects(
2249 store: &Path,
2250 actions: impl IntoIterator<Item = CacheDigest>,
2251) -> HashSet<PathBuf> {
2252 let cas = LocalCas::new(store);
2253 let mut rooted = HashSet::new();
2254 let mut visited = BTreeSet::new();
2255 for action in actions {
2256 let Some(result) = read_action_result(store, &action) else {
2257 continue;
2258 };
2259 root_digest(&cas, &mut rooted, &result.action);
2260 if let Some(metadata) = &result.metadata {
2261 root_digest(&cas, &mut rooted, metadata);
2262 if let Some(rustc) = read_rustc_metadata(&cas, metadata) {
2269 root_digest(&cas, &mut rooted, &rustc.stdout);
2270 root_digest(&cas, &mut rooted, &rustc.stderr);
2271 }
2272 }
2273 if let Some(output_root) = &result.output_root {
2274 root_digest(&cas, &mut rooted, output_root);
2275 root_tree(&cas, output_root, &mut rooted, &mut visited);
2276 }
2277 }
2278 rooted
2279}
2280
2281fn read_action_result(store: &Path, action: &CacheDigest) -> Option<RemoteActionResult> {
2282 let path = mbx_cache_core::LocalActionCache::new(store)
2283 .path_for(action)
2284 .ok()?;
2285 let bytes = std::fs::read(path).ok()?;
2286 serde_json::from_slice(&bytes).ok()
2287}
2288
2289fn root_tree(
2290 cas: &LocalCas,
2291 output_root: &CacheDigest,
2292 rooted: &mut HashSet<PathBuf>,
2293 visited: &mut BTreeSet<CacheDigest>,
2294) {
2295 let mut pending = vec![output_root.clone()];
2296 while let Some(digest) = pending.pop() {
2297 if !visited.insert(digest.clone()) {
2298 continue;
2299 }
2300 let Some(directory) = read_directory(cas, &digest) else {
2301 continue;
2302 };
2303 for file in &directory.files {
2304 root_digest(cas, rooted, &file.digest);
2305 }
2306 for child in &directory.directories {
2307 root_digest(cas, rooted, &child.digest);
2308 pending.push(child.digest.clone());
2309 }
2310 }
2311}
2312
2313fn read_rustc_metadata(cas: &LocalCas, digest: &CacheDigest) -> Option<RustcMetadata> {
2314 let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2315 serde_json::from_slice(&bytes).ok()
2316}
2317
2318fn read_directory(cas: &LocalCas, digest: &CacheDigest) -> Option<CacheDirectory> {
2319 let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2320 serde_json::from_slice(&bytes).ok()
2321}
2322
2323fn root_digest(cas: &LocalCas, rooted: &mut HashSet<PathBuf>, digest: &CacheDigest) {
2324 if let Ok(path) = cas.path_for(digest) {
2325 rooted.insert(path);
2326 }
2327}
2328
2329#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2330enum Removal {
2331 Removed,
2332 Missing,
2333 Blocked,
2334}
2335
2336fn remove(path: &Path) -> Result<Removal> {
2337 match std::fs::remove_file(path) {
2338 Ok(()) => Ok(Removal::Removed),
2339 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Removal::Missing),
2341 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2346 log::debug!("could not evict {}: {error}", path.display());
2347 Ok(Removal::Blocked)
2348 }
2349 Err(error) => Err(error).wrap_err_with(|| format!("failed to evict {}", path.display())),
2350 }
2351}
2352
2353#[derive(Debug, Default, PartialEq, Eq)]
2354struct ObjectEvictions {
2355 removed_objects: u64,
2356 removed_bytes: u64,
2357 remaining_bytes: u64,
2358}
2359
2360fn evict_objects(
2363 objects: &[Entry],
2364 rooted: &HashSet<PathBuf>,
2365 mut live_bytes: u64,
2366 max_bytes: u64,
2367 mut remove_file: impl FnMut(&Path) -> Result<Removal>,
2368) -> Result<ObjectEvictions> {
2369 let mut outcome = ObjectEvictions::default();
2370 let mut blocked_unrooted = false;
2371 for entry in objects {
2372 if live_bytes <= max_bytes {
2373 break;
2374 }
2375 let is_rooted = rooted.contains(&entry.path);
2376 if is_rooted && blocked_unrooted {
2377 break;
2381 }
2382 match remove_file(&entry.path)? {
2383 Removal::Removed => {
2384 live_bytes = live_bytes.saturating_sub(entry.size);
2385 outcome.removed_objects += 1;
2386 outcome.removed_bytes += entry.size;
2387 }
2388 Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
2389 Removal::Blocked if !is_rooted => blocked_unrooted = true,
2390 Removal::Blocked => {}
2391 }
2392 }
2393 outcome.remaining_bytes = live_bytes;
2394 Ok(outcome)
2395}
2396
2397fn action_result_is_dangling(
2415 cas: &LocalCas,
2416 path: &Path,
2417 virtually_removed: &HashSet<PathBuf>,
2418) -> Result<bool> {
2419 let bytes = match std::fs::read(path) {
2420 Ok(bytes) => bytes,
2421 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
2422 Err(error) => {
2423 return Err(error).wrap_err_with(|| format!("failed to read {}", path.display()));
2424 }
2425 };
2426 let Ok(result) = serde_json::from_slice::<RemoteActionResult>(&bytes) else {
2428 return Ok(false);
2429 };
2430 for digest in [
2431 Some(&result.action),
2432 result.metadata.as_ref(),
2433 result.output_root.as_ref(),
2434 ]
2435 .into_iter()
2436 .flatten()
2437 {
2438 match cas.path_for(digest) {
2439 Ok(path) => {
2440 if virtually_removed.contains(&path) || !path.exists() {
2441 return Ok(true);
2442 }
2443 }
2444 Err(_) => return Ok(true),
2446 }
2447 }
2448 Ok(false)
2449}
2450
2451struct Entry {
2452 path: PathBuf,
2453 size: u64,
2454 used: SystemTime,
2455}
2456
2457fn walk_files(root: &Path) -> Result<Vec<Entry>> {
2458 let mut entries = Vec::new();
2459 let mut pending = vec![root.to_path_buf()];
2460 while let Some(directory) = pending.pop() {
2461 let listing = match std::fs::read_dir(&directory) {
2462 Ok(listing) => listing,
2463 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2464 Err(error) => {
2465 return Err(error)
2466 .wrap_err_with(|| format!("failed to read {}", directory.display()));
2467 }
2468 };
2469 for entry in listing {
2470 let entry = entry?;
2471 let metadata = match entry.metadata() {
2472 Ok(metadata) => metadata,
2473 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2475 Err(error) => return Err(error.into()),
2476 };
2477 if metadata.is_dir() {
2478 pending.push(entry.path());
2479 } else if metadata.is_file() {
2480 let used = metadata
2481 .accessed()
2482 .or_else(|_| metadata.modified())
2483 .unwrap_or(SystemTime::UNIX_EPOCH);
2484 entries.push(Entry {
2485 path: entry.path(),
2486 size: metadata.len(),
2487 used,
2488 });
2489 }
2490 }
2491 }
2492 Ok(entries)
2493}
2494
2495fn read_dir_or_empty(root: &Path) -> Result<Vec<std::fs::DirEntry>> {
2496 match std::fs::read_dir(root) {
2497 Ok(entries) => entries
2498 .collect::<std::io::Result<Vec<_>>>()
2499 .map_err(Into::into),
2500 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
2501 Err(error) => Err(error).wrap_err_with(|| format!("failed to read {}", root.display())),
2502 }
2503}
2504
2505fn digest_from_path(path: &Path, action_result: bool) -> Option<CacheDigest> {
2506 let name = path.file_name()?.to_str()?;
2507 let name = if action_result {
2508 name.strip_suffix(".json")?
2509 } else {
2510 name
2511 };
2512 let (hash, size) = name.rsplit_once('-')?;
2513 let algorithm = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
2514 let digest = CacheDigest {
2515 algorithm,
2516 hash: hash.to_string(),
2517 size: size.parse().ok()?,
2518 };
2519 digest.validate().ok()?;
2520 Some(digest)
2521}
2522
2523fn addressed_digest(store: &Path, path: &Path, action_result: bool) -> Option<CacheDigest> {
2524 let digest = digest_from_path(path, action_result)?;
2525 let canonical = if action_result {
2526 mbx_cache_core::LocalActionCache::new(store)
2527 .path_for(&digest)
2528 .ok()?
2529 } else {
2530 LocalCas::new(store).path_for(&digest).ok()?
2531 };
2532 (canonical == path).then_some(digest)
2533}
2534
2535fn cached_tree_bytes(cache: &mut BTreeMap<PathBuf, u64>, root: &Path) -> u64 {
2536 *cache
2537 .entry(root.to_path_buf())
2538 .or_insert_with(|| tree_bytes(root))
2539}
2540
2541fn tree_bytes(root: &Path) -> u64 {
2542 let Ok(root_metadata) = std::fs::metadata(root) else {
2543 return 0;
2544 };
2545 if root_metadata.is_file() {
2546 return root_metadata.len();
2547 }
2548 let Ok(entries) = std::fs::read_dir(root) else {
2549 return 0;
2550 };
2551 let mut bytes = 0_u64;
2552 let mut pending = entries
2553 .filter_map(std::result::Result::ok)
2554 .map(|entry| entry.path())
2555 .collect::<Vec<_>>();
2556 while let Some(path) = pending.pop() {
2557 let Ok(metadata) = std::fs::symlink_metadata(&path) else {
2558 continue;
2559 };
2560 if metadata.is_file() {
2561 bytes = bytes.saturating_add(metadata.len());
2562 } else if metadata.is_dir()
2563 && let Ok(entries) = std::fs::read_dir(path)
2564 {
2565 pending.extend(
2566 entries
2567 .filter_map(std::result::Result::ok)
2568 .map(|entry| entry.path()),
2569 );
2570 }
2571 }
2572 bytes
2573}
2574
2575#[cfg(test)]
2576#[path = "store_tests.rs"]
2577mod tests;
2578
2579fn write_advisory(path: &Path, contents: &[u8]) -> Result<()> {
2582 let parent = path
2583 .parent()
2584 .filter(|parent| !parent.as_os_str().is_empty())
2585 .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
2586 std::fs::create_dir_all(parent)
2587 .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
2588 let mut temporary = tempfile::Builder::new()
2589 .prefix(".mbx-")
2590 .tempfile_in(parent)?;
2591 use std::io::Write as _;
2592 temporary.write_all(contents)?;
2593 temporary
2594 .persist(path)
2595 .map_err(|error| error.error)
2596 .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
2597 Ok(())
2598}
2599
2600fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
2601 let parent = path
2602 .parent()
2603 .filter(|parent| !parent.as_os_str().is_empty())
2604 .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
2605 std::fs::create_dir_all(parent)
2606 .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
2607 let mut temporary = tempfile::Builder::new()
2608 .prefix(".mbx-")
2609 .tempfile_in(parent)?;
2610 use std::io::Write as _;
2611 temporary.write_all(contents)?;
2612 temporary.as_file_mut().sync_all()?;
2613 temporary
2614 .persist(path)
2615 .map_err(|error| error.error)
2616 .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
2617 Ok(())
2618}