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
759 .results
760 .iter()
761 .map(|path| {
762 let result: RemoteActionResult = serde_json::from_slice(&std::fs::read(path)?)
763 .wrap_err_with(|| format!("action result is invalid: {}", path.display()))?;
764 Ok(result)
765 })
766 .collect::<Result<Vec<_>>>()
767 .wrap_err("cache export is incomplete or corrupt")?;
768
769 let cas = LocalCas::new(store);
770 for path in &closure.objects {
771 let relative = path.strip_prefix(root)?;
772 let source = root.join(relative);
773 let digest = addressed_digest(root, &source, false)
774 .ok_or_else(|| eyre::eyre!("invalid cache object path {}", relative.display()))?;
775 cas.adopt_verified_file(&digest, &source)?;
779 }
780 let action_cache = mbx_cache_core::LocalActionCache::new(store);
781 for result in &results {
782 action_cache.store(result)?;
783 }
784 for task in manifest.tasks {
785 merge_imported_manifest(store, task)?;
786 }
787 let bytes = match bundle_bytes {
788 Some(bytes) => {
789 std::fs::remove_dir_all(archive)
793 .wrap_err_with(|| format!("failed to remove {}", archive.display()))?;
794 bytes
795 }
796 None => std::fs::metadata(archive)?.len(),
797 };
798 Ok(ImportOutcome {
799 transfer: TransferOutcome {
800 actions: actions.len() as u64,
801 objects: closure.objects.len() as u64,
802 bytes,
803 },
804 attachments: manifest.attachments,
805 })
806}
807
808fn validate_export_additions(additions: &ExportAdditions) -> Result<()> {
809 if additions
810 .attachments
811 .keys()
812 .any(|name| !valid_attachment_name(name))
813 || additions
814 .attachments
815 .values()
816 .any(|digest| !additions.objects.contains(digest))
817 || additions
818 .objects
819 .iter()
820 .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
821 {
822 eyre::bail!("invalid cache export attachment");
823 }
824 Ok(())
825}
826
827fn valid_attachment_name(name: &str) -> bool {
828 !name.is_empty()
829 && name.len() <= 128
830 && name
831 .bytes()
832 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
833}
834
835#[derive(Default)]
837struct Closure {
838 objects: BTreeSet<PathBuf>,
839 results: BTreeSet<PathBuf>,
840 pending: Vec<PendingObject>,
841}
842
843struct PendingObject {
845 digest: CacheDigest,
846 path: PathBuf,
847}
848
849fn strict_closure(store: &Path, actions: &BTreeSet<CacheDigest>) -> Result<Closure> {
850 let cas = LocalCas::new(store);
851 let action_cache = mbx_cache_core::LocalActionCache::new(store);
852 let mut closure = Closure::default();
853 let mut directories = BTreeSet::new();
854 for action in actions {
855 let result = action_cache
856 .find(action)?
857 .ok_or_else(|| eyre::eyre!("action result is missing for {}", action.hash))?;
858 closure.results.insert(action_cache.path_for(action)?);
859 require_object(&cas, &mut closure, &result.action)?;
860 if let Some(metadata) = &result.metadata {
861 let path = require_parsed_object(&cas, &mut closure, metadata)?;
862 let captured: CapturedOutput = serde_json::from_slice(&std::fs::read(path)?)
863 .wrap_err("action metadata is invalid")?;
864 require_object(&cas, &mut closure, &captured.stdout)?;
865 require_object(&cas, &mut closure, &captured.stderr)?;
866 }
867 if let Some(root) = &result.output_root {
868 let mut nodes = vec![root.clone()];
869 while let Some(digest) = nodes.pop() {
870 if !directories.insert(digest.clone()) {
871 continue;
872 }
873 let path = require_parsed_object(&cas, &mut closure, &digest)?;
874 let directory: CacheDirectory = serde_json::from_slice(&std::fs::read(path)?)
875 .wrap_err("output directory is invalid")?;
876 for file in directory.files {
877 require_object(&cas, &mut closure, &file.digest)?;
878 }
879 for child in directory.directories {
880 nodes.push(child.digest);
881 }
882 }
883 }
884 }
885 Ok(closure)
886}
887
888fn verify_pending(pending: &mut [PendingObject]) -> Result<()> {
896 if pending.is_empty() {
897 return Ok(());
898 }
899 pending.sort_by(|left, right| {
903 right
904 .digest
905 .size
906 .cmp(&left.digest.size)
907 .then_with(|| left.path.cmp(&right.path))
908 });
909 let workers = std::thread::available_parallelism()
910 .map(usize::from)
911 .unwrap_or(1)
912 .min(pending.len());
913 let next = AtomicUsize::new(0);
914 let failure: Mutex<Option<(PathBuf, eyre::Report)>> = Mutex::new(None);
917 let pending: &[PendingObject] = pending;
918 std::thread::scope(|scope| {
919 for _ in 0..workers {
920 scope.spawn(|| {
921 loop {
922 let index = next.fetch_add(1, Ordering::Relaxed);
923 let Some(entry) = pending.get(index) else {
924 break;
925 };
926 let error = match entry.digest.matches_file(&entry.path) {
927 Ok(true) => continue,
928 Ok(false) => eyre::eyre!(
929 "local CAS blob failed digest verification: {}",
930 entry.path.display()
931 ),
932 Err(error) => {
933 error.wrap_err(format!("failed to verify {}", entry.path.display()))
934 }
935 };
936 let mut failure = failure.lock().unwrap_or_else(|error| error.into_inner());
937 if failure.as_ref().is_none_or(|(path, _)| entry.path < *path) {
938 *failure = Some((entry.path.clone(), error));
939 }
940 }
941 });
942 }
943 });
944 match failure
945 .into_inner()
946 .unwrap_or_else(|error| error.into_inner())
947 {
948 Some((_, error)) => Err(error),
949 None => Ok(()),
950 }
951}
952
953#[derive(Deserialize)]
954struct CapturedOutput {
955 stdout: CacheDigest,
956 stderr: CacheDigest,
957}
958
959fn merge_imported_manifest(destination: &Path, mut imported: TaskActionManifest) -> Result<()> {
960 let identity = imported.task.clone();
961 let destination_path = task_manifest_path(destination, &identity);
962 let lock_path = task_manifest_lock_path(destination, &identity);
963 std::fs::create_dir_all(lock_path.parent().expect("task manifest lock has a parent"))?;
964 let mut lock = fslock::LockFile::open(&lock_path)?;
965 lock.lock()?;
966 if let Ok(bytes) = std::fs::read(&destination_path)
967 && let Ok(existing) = serde_json::from_slice::<TaskActionManifest>(&bytes)
968 && existing.task == identity
969 && existing.validate()
970 {
971 let existing_predictions: BTreeMap<_, _> = existing
972 .predictions
973 .iter()
974 .cloned()
975 .map(|prediction| (prediction.invocation.clone(), prediction))
976 .collect();
977 let updates = imported
980 .predictions
981 .into_iter()
982 .map(|prediction| {
983 existing_predictions
984 .get(&prediction.invocation)
985 .cloned()
986 .unwrap_or(prediction)
987 })
988 .collect();
989 imported.predictions =
990 merge_task_action_predictions(existing.predictions, updates, &BTreeSet::new())?;
991 }
992 write_atomic(&destination_path, &serde_json::to_vec(&imported)?)
993}
994
995fn require_object(cas: &LocalCas, closure: &mut Closure, digest: &CacheDigest) -> Result<()> {
1002 let expected = cas.path_for(digest)?;
1003 if !closure.objects.insert(expected.clone()) {
1004 return Ok(());
1005 }
1006 let metadata = match std::fs::metadata(&expected) {
1007 Ok(metadata) => metadata,
1008 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1009 eyre::bail!("cache object is missing for {}", digest.hash)
1010 }
1011 Err(error) => {
1012 return Err(error).wrap_err_with(|| format!("failed to read {}", expected.display()));
1013 }
1014 };
1015 if metadata.len() != digest.size {
1016 eyre::bail!(
1017 "local CAS blob failed digest verification: {}",
1018 expected.display()
1019 );
1020 }
1021 closure.pending.push(PendingObject {
1022 digest: digest.clone(),
1023 path: expected,
1024 });
1025 Ok(())
1026}
1027
1028fn require_parsed_object(
1035 cas: &LocalCas,
1036 closure: &mut Closure,
1037 digest: &CacheDigest,
1038) -> Result<PathBuf> {
1039 let path = cas
1040 .find(digest)?
1041 .ok_or_else(|| eyre::eyre!("cache object is missing for {}", digest.hash))?;
1042 closure.objects.insert(path.clone());
1043 Ok(path)
1044}
1045
1046fn task_manifest_path(store: &Path, identity: &str) -> PathBuf {
1047 store
1048 .join("task-manifests/v1")
1049 .join(format!("{identity}.json"))
1050}
1051
1052fn task_manifest_lock_path(store: &Path, identity: &str) -> PathBuf {
1053 store
1054 .join("task-manifests/v1/locks")
1055 .join(format!("{identity}.lock"))
1056}
1057
1058fn append_file(builder: &mut tar::Builder<std::fs::File>, root: &Path, path: &Path) -> Result<()> {
1059 let name = path.strip_prefix(root)?;
1060 builder.append_path_with_name(path, name)?;
1061 Ok(())
1062}
1063
1064fn append_bytes(
1065 builder: &mut tar::Builder<std::fs::File>,
1066 name: &Path,
1067 bytes: &[u8],
1068) -> Result<()> {
1069 let mut header = tar::Header::new_gnu();
1070 header.set_size(bytes.len() as u64);
1071 header.set_mode(0o644);
1072 header.set_cksum();
1073 builder.append_data(&mut header, name, bytes)?;
1074 Ok(())
1075}
1076
1077fn unpack_archive(archive: &Path, staging: &Path) -> Result<()> {
1079 let file = std::fs::File::open(archive)
1080 .wrap_err_with(|| format!("failed to open {}", archive.display()))?;
1081 let mut bundle = tar::Archive::new(file);
1082 let mut seen = BTreeSet::new();
1083 for entry in bundle.entries()? {
1084 let mut entry = entry?;
1085 let entry_type = entry.header().entry_type();
1086 if !entry_type.is_file() && !entry_type.is_gnu_sparse() {
1087 eyre::bail!("cache export contains a non-file entry");
1088 }
1089 let path = entry.path()?.into_owned();
1090 validate_archive_path(&path)?;
1091 if !seen.insert(path.clone()) {
1092 eyre::bail!("cache export contains duplicate entry {}", path.display());
1093 }
1094 let destination = staging.join(&path);
1095 std::fs::create_dir_all(destination.parent().expect("entry has a parent"))?;
1096 entry.unpack(&destination)?;
1100 }
1101 Ok(())
1102}
1103
1104fn validate_directory_bundle(root: &Path) -> Result<()> {
1112 let mut pending = vec![root.to_path_buf()];
1113 while let Some(directory) = pending.pop() {
1114 for entry in std::fs::read_dir(&directory)
1115 .wrap_err_with(|| format!("failed to read {}", directory.display()))?
1116 {
1117 let entry = entry?;
1118 let metadata = entry.metadata()?;
1119 let path = entry.path();
1120 if metadata.is_dir() {
1121 pending.push(path);
1122 continue;
1123 }
1124 if !metadata.is_file() {
1125 eyre::bail!("cache export contains a non-file entry");
1126 }
1127 validate_archive_path(path.strip_prefix(root)?)?;
1128 reject_linked_file(&metadata, &path)?;
1129 }
1130 }
1131 Ok(())
1132}
1133
1134#[cfg(unix)]
1141fn reject_linked_file(metadata: &std::fs::Metadata, path: &Path) -> Result<()> {
1142 use std::os::unix::fs::MetadataExt as _;
1143
1144 if metadata.nlink() > 1 {
1145 eyre::bail!("cache export contains a hard link at {}", path.display());
1146 }
1147 Ok(())
1148}
1149
1150#[cfg(not(unix))]
1156fn reject_linked_file(_metadata: &std::fs::Metadata, _path: &Path) -> Result<()> {
1157 Ok(())
1158}
1159
1160fn validate_archive_path(path: &Path) -> Result<()> {
1161 if path.as_os_str().is_empty()
1162 || path.is_absolute()
1163 || path
1164 .components()
1165 .any(|component| !matches!(component, Component::Normal(_)))
1166 {
1167 eyre::bail!("cache export contains unsafe path {}", path.display());
1168 }
1169 let parts = path
1174 .components()
1175 .map(|component| component.as_os_str().to_string_lossy().into_owned())
1176 .collect::<Vec<_>>();
1177 let expected = match parts.as_slice() {
1178 [name] => name == EXPORT_MANIFEST,
1179 [root, version, _rest @ ..] => {
1180 parts.len() > 2 && (root == "cas" || root == "action-results") && version == "v1"
1181 }
1182 [] => false,
1183 };
1184 if !expected {
1185 eyre::bail!("cache export contains unexpected path {}", path.display());
1186 }
1187 Ok(())
1188}
1189
1190#[derive(Debug, Clone, Serialize, Deserialize)]
1197#[serde(deny_unknown_fields)]
1198struct CheckoutRecord {
1199 version: u8,
1200 workspace_root: PathBuf,
1201 target_dir: PathBuf,
1202 updated_secs: u64,
1203}
1204
1205pub fn stats(store: &Path) -> Result<StoreStats> {
1207 let objects = walk_files(&store.join(CAS_DIR))?;
1208 let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
1209 let checkouts = scan_checkouts(store)?;
1210 Ok(StoreStats {
1211 objects: objects.len() as u64,
1212 object_bytes: objects.iter().map(|entry| entry.size).sum(),
1213 action_results: results.len() as u64,
1214 action_result_bytes: results.iter().map(|entry| entry.size).sum(),
1215 live_checkouts: checkouts.live_records,
1216 stale_checkouts: checkouts.stale_records.len() as u64,
1217 })
1218}
1219
1220fn checkout_target_bytes(sizes: &mut BTreeMap<PathBuf, u64>, record: &CheckoutRecord) -> u64 {
1227 if record.target_dir == record.workspace_root {
1228 return 0;
1229 }
1230 cached_tree_bytes(sizes, &record.target_dir)
1231}
1232
1233pub fn projects(store: &Path) -> Result<Vec<ProjectUsage>> {
1239 project_usage(store, true)
1240}
1241
1242pub fn live_project_cache_bytes(store: &Path) -> Result<Vec<u64>> {
1248 Ok(project_usage(store, false)?
1249 .into_iter()
1250 .filter(|project| project.live)
1251 .map(|project| project.action_bytes)
1252 .collect())
1253}
1254
1255fn project_usage(store: &Path, size_targets: bool) -> Result<Vec<ProjectUsage>> {
1256 let mut projects: BTreeMap<PathBuf, (BTreeSet<String>, bool, u64)> = BTreeMap::new();
1257 let mut target_sizes = BTreeMap::new();
1258 let root = store.join(CHECKOUTS_DIR);
1259 for identity_entry in read_dir_or_empty(&root)? {
1260 let identity = identity_entry.file_name().to_string_lossy().into_owned();
1261 if !is_task_identity(&identity) || !identity_entry.file_type()?.is_dir() {
1262 continue;
1263 }
1264 for entry in walk_files(&identity_entry.path())? {
1265 let Some(record) = read_checkout_record(&entry.path) else {
1266 continue;
1267 };
1268 let project = projects.entry(record.workspace_root.clone()).or_default();
1269 if claim_is_live(store, &record) {
1270 project.0.insert(identity.clone());
1271 project.1 = true;
1272 }
1273 if size_targets {
1274 project.2 = project
1275 .2
1276 .max(checkout_target_bytes(&mut target_sizes, &record));
1277 }
1278 }
1279 }
1280 let live = projects
1281 .values()
1282 .flat_map(|(identities, _, _)| identities.iter().cloned())
1283 .collect::<BTreeSet<_>>();
1284 let mut reachability = Reachability::new(store);
1285 resolve_identities(&reachability, &live);
1286 let mut usages = Vec::new();
1287 for (workspace_root, (identities, live, target_bytes)) in projects {
1288 let action_bytes = reachability.bytes(&identities);
1289 usages.push(ProjectUsage {
1290 workspace_root,
1291 identities: identities.len() as u64,
1292 action_bytes,
1293 target_bytes,
1294 live,
1295 });
1296 }
1297 usages.sort_by(|left, right| {
1298 right
1299 .action_bytes
1300 .saturating_add(right.target_bytes)
1301 .cmp(&left.action_bytes.saturating_add(left.target_bytes))
1302 .then_with(|| left.workspace_root.cmp(&right.workspace_root))
1303 });
1304 Ok(usages)
1305}
1306
1307fn resolve_identities(reachability: &Reachability, identities: &BTreeSet<String>) {
1315 const MAX_WORKERS: usize = 8;
1316 let identities = identities.iter().collect::<Vec<_>>();
1317 let workers = std::thread::available_parallelism()
1318 .map(usize::from)
1319 .unwrap_or(1)
1320 .min(MAX_WORKERS)
1321 .min(identities.len());
1322 let next = AtomicUsize::new(0);
1323 std::thread::scope(|scope| {
1324 for _ in 0..workers {
1325 scope.spawn(|| {
1326 while let Some(identity) = identities.get(next.fetch_add(1, Ordering::Relaxed)) {
1327 let actions = task_manifest_actions(&reachability.store, identity)
1331 .unwrap_or_else(|error| {
1332 log::debug!("could not read the manifest for {identity}: {error}");
1333 Vec::new()
1334 });
1335 reachability.record(identity, &actions);
1336 }
1337 });
1338 }
1339 });
1340}
1341
1342struct Reachability {
1355 store: PathBuf,
1356 cas: LocalCas,
1357 action_cache: mbx_cache_core::LocalActionCache,
1358 paths: Mutex<PathIndex>,
1360 identities: Mutex<HashMap<String, ActionPaths>>,
1361 actions: Mutex<HashMap<CacheDigest, Arc<[usize]>>>,
1362 directories: Mutex<HashMap<CacheDigest, Option<DirectoryPaths>>>,
1364 counted: Vec<usize>,
1366 workspaces: usize,
1367}
1368
1369#[derive(Default)]
1370struct PathIndex {
1371 indices: HashMap<PathBuf, usize>,
1372 sizes: Vec<u64>,
1373}
1374
1375type ActionPaths = Arc<[Arc<[usize]>]>;
1377
1378type DirectoryPaths = (Arc<[CacheDigest]>, Arc<[usize]>);
1379
1380fn locked<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
1383 mutex.lock().unwrap_or_else(|error| error.into_inner())
1384}
1385
1386impl Reachability {
1387 fn new(store: &Path) -> Self {
1388 Self {
1389 store: store.to_path_buf(),
1390 cas: LocalCas::new(store),
1391 action_cache: mbx_cache_core::LocalActionCache::new(store),
1392 paths: Mutex::default(),
1393 identities: Mutex::default(),
1394 actions: Mutex::default(),
1395 directories: Mutex::default(),
1396 counted: Vec::new(),
1397 workspaces: 0,
1398 }
1399 }
1400
1401 fn bytes(&mut self, identities: &BTreeSet<String>) -> u64 {
1405 let reachable = {
1406 let recorded = locked(&self.identities);
1407 identities
1408 .iter()
1409 .filter_map(|identity| recorded.get(identity).cloned())
1410 .collect::<Vec<_>>()
1411 };
1412 let sizes = std::mem::take(&mut locked(&self.paths).sizes);
1413 self.workspaces += 1;
1414 self.counted.resize(sizes.len(), 0);
1415 let mut bytes = 0_u64;
1416 for index in reachable
1417 .iter()
1418 .flat_map(|actions| actions.iter())
1419 .flat_map(|paths| paths.iter())
1420 {
1421 if self.counted[*index] != self.workspaces {
1422 self.counted[*index] = self.workspaces;
1423 bytes = bytes.saturating_add(sizes[*index]);
1424 }
1425 }
1426 locked(&self.paths).sizes = sizes;
1427 bytes
1428 }
1429
1430 fn record(&self, identity: &str, actions: &[CacheDigest]) {
1435 let actions = actions
1436 .iter()
1437 .map(|action| self.action(action))
1438 .collect::<Arc<[_]>>();
1439 locked(&self.identities).insert(identity.to_string(), actions);
1440 }
1441
1442 fn action(&self, action: &CacheDigest) -> Arc<[usize]> {
1443 if let Some(paths) = locked(&self.actions).get(action) {
1444 return paths.clone();
1445 }
1446 let mut paths = Vec::new();
1447 if let Ok(path) = self.action_cache.path_for(action) {
1448 paths.push(self.index(path));
1449 }
1450 if let Some(result) = read_action_result(&self.store, action) {
1451 self.push_digest(&mut paths, &result.action);
1452 if let Some(metadata) = &result.metadata {
1453 self.push_digest(&mut paths, metadata);
1454 if let Some(rustc) = read_rustc_metadata(&self.cas, metadata) {
1455 self.push_digest(&mut paths, &rustc.stdout);
1456 self.push_digest(&mut paths, &rustc.stderr);
1457 }
1458 }
1459 if let Some(output_root) = &result.output_root {
1460 self.push_digest(&mut paths, output_root);
1461 let mut pending = vec![output_root.clone()];
1462 let mut visited = HashSet::new();
1463 while let Some(digest) = pending.pop() {
1464 if !visited.insert(digest.clone()) {
1465 continue;
1466 }
1467 let Some((children, named)) = self.directory(&digest) else {
1468 continue;
1469 };
1470 paths.extend(named.iter().copied());
1471 pending.extend(children.iter().cloned());
1472 }
1473 }
1474 }
1475 paths.sort_unstable();
1476 paths.dedup();
1477 let paths = Arc::<[usize]>::from(paths);
1478 locked(&self.actions).insert(action.clone(), paths.clone());
1479 paths
1480 }
1481
1482 fn directory(&self, digest: &CacheDigest) -> Option<DirectoryPaths> {
1484 if let Some(entry) = locked(&self.directories).get(digest) {
1485 return entry.clone();
1486 }
1487 let entry = read_directory(&self.cas, digest).map(|directory| {
1488 let mut paths = Vec::new();
1489 for file in &directory.files {
1490 self.push_digest(&mut paths, &file.digest);
1491 }
1492 for child in &directory.directories {
1493 self.push_digest(&mut paths, &child.digest);
1494 }
1495 let children = directory
1496 .directories
1497 .into_iter()
1498 .map(|child| child.digest)
1499 .collect::<Arc<[_]>>();
1500 (children, Arc::<[usize]>::from(paths))
1501 });
1502 locked(&self.directories).insert(digest.clone(), entry.clone());
1503 entry
1504 }
1505
1506 fn push_digest(&self, paths: &mut Vec<usize>, digest: &CacheDigest) {
1507 if let Ok(path) = self.cas.path_for(digest) {
1508 paths.push(self.index(path));
1509 }
1510 }
1511
1512 fn index(&self, path: PathBuf) -> usize {
1513 if let Some(index) = locked(&self.paths).indices.get(&path) {
1514 return *index;
1515 }
1516 let size = std::fs::metadata(&path).map_or(0, |metadata| metadata.len());
1519 let mut paths = locked(&self.paths);
1520 if let Some(index) = paths.indices.get(&path) {
1521 return *index;
1522 }
1523 let index = paths.sizes.len();
1524 paths.sizes.push(size);
1525 paths.indices.insert(path, index);
1526 index
1527 }
1528}
1529
1530pub fn largest(store: &Path, limit: usize) -> Result<Vec<LargestEntry>> {
1532 let mut entries = Vec::new();
1533 for (kind, root, action_result) in [
1534 ("object", store.join(CAS_DIR), false),
1535 ("action result", store.join(ACTION_RESULTS_DIR), true),
1536 ] {
1537 entries.extend(
1538 walk_files(&root)?
1539 .into_iter()
1540 .filter(|entry| addressed_digest(store, &entry.path, action_result).is_some())
1541 .map(|entry| LargestEntry {
1542 kind,
1543 path: entry.path,
1544 bytes: entry.size,
1545 }),
1546 );
1547 }
1548 entries.sort_by(|left, right| {
1549 right
1550 .bytes
1551 .cmp(&left.bytes)
1552 .then_with(|| left.path.cmp(&right.path))
1553 });
1554 entries.truncate(limit);
1555 Ok(entries)
1556}
1557
1558pub fn verify(store: &Path) -> Result<VerifyOutcome> {
1560 let cas = LocalCas::new(store);
1561 let action_cache = mbx_cache_core::LocalActionCache::new(store);
1562 let virtually_removed = HashSet::new();
1565 let mut outcome = VerifyOutcome::default();
1566 for entry in walk_files(&store.join(CAS_DIR))? {
1567 let Some(digest) = addressed_digest(store, &entry.path, false) else {
1568 continue;
1569 };
1570 outcome.checked_objects += 1;
1571 if !matches!(cas.find(&digest), Ok(Some(_))) {
1572 outcome.problems.push(entry.path);
1573 }
1574 }
1575 for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
1576 let Some(digest) = addressed_digest(store, &entry.path, true) else {
1577 continue;
1578 };
1579 outcome.checked_action_results += 1;
1580 if !matches!(action_cache.find(&digest), Ok(Some(_)))
1581 || action_result_is_dangling(&cas, &entry.path, &virtually_removed)?
1582 {
1583 outcome.problems.push(entry.path);
1584 }
1585 }
1586 outcome.problems.sort();
1587 Ok(outcome)
1588}
1589
1590pub fn remove_project(store: &Path, workspace_root: &Path) -> Result<RemoveProjectOutcome> {
1592 let mut outcome = RemoveProjectOutcome::default();
1593 for entry in walk_files(&store.join(CHECKOUTS_DIR))? {
1594 if read_checkout_record(&entry.path)
1595 .is_some_and(|record| record.workspace_root == workspace_root)
1596 {
1597 std::fs::remove_file(&entry.path)?;
1598 outcome.removed_checkout_records += 1;
1599 if let Some(parent) = entry.path.parent() {
1600 let _ = std::fs::remove_dir(parent);
1601 }
1602 }
1603 }
1604 match std::fs::remove_file(latest_receipt_path(store, workspace_root)) {
1605 Ok(()) => {}
1606 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1607 Err(error) => return Err(error.into()),
1608 }
1609 Ok(outcome)
1610}
1611
1612pub fn record_checkout(
1624 store: &Path,
1625 identity: &str,
1626 workspace_root: &Path,
1627 target_dir: &Path,
1628) -> Result<()> {
1629 let record = CheckoutRecord {
1630 version: CHECKOUT_RECORD_VERSION,
1631 workspace_root: workspace_root.to_path_buf(),
1632 target_dir: target_dir.to_path_buf(),
1633 updated_secs: SystemTime::now()
1634 .duration_since(UNIX_EPOCH)
1635 .map(|since| since.as_secs())
1636 .unwrap_or_default(),
1637 };
1638 let mut contents = serde_json::to_vec(&record)?;
1639 contents.push(b'\n');
1640 write_advisory(
1643 &checkout_record_path(store, identity, workspace_root),
1644 &contents,
1645 )
1646}
1647
1648fn checkout_record_path(store: &Path, identity: &str, workspace_root: &Path) -> PathBuf {
1649 let key = CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash;
1653 store
1654 .join(CHECKOUTS_DIR)
1655 .join(identity)
1656 .join(format!("{key}.json"))
1657}
1658
1659pub fn gc(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
1669 gc_with_mode(store, max_bytes, false)
1670}
1671
1672pub fn gc_dry_run(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
1674 gc_with_mode(store, max_bytes, true)
1675}
1676
1677fn gc_with_mode(store: &Path, max_bytes: u64, dry_run: bool) -> Result<GcOutcome> {
1678 let mut objects = walk_files(&store.join(CAS_DIR))?;
1679 let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
1680 let mut live_bytes = objects
1681 .iter()
1682 .chain(results.iter())
1683 .map(|entry| entry.size)
1684 .sum::<u64>();
1685
1686 let mut outcome = GcOutcome::default();
1687
1688 let checkouts = scan_checkouts(store)?;
1691 for path in &checkouts.stale_records {
1692 if dry_run || matches!(remove(path)?, Removal::Removed) {
1693 outcome.removed_checkout_records += 1;
1694 }
1695 if !dry_run && let Some(parent) = path.parent() {
1698 let _ = std::fs::remove_dir(parent);
1699 }
1700 }
1701
1702 let mut virtually_removed = HashSet::new();
1703 if live_bytes > max_bytes {
1704 let mut rooted = rooted_objects(store, &checkouts.live_identities)?;
1708 rooted.extend(rooted_action_objects(
1715 store,
1716 grouped_receipt_actions(store)?,
1717 ));
1718 objects.sort_by_cached_key(|entry| (rooted.contains(&entry.path), entry.used));
1722 let evicted = evict_objects(&objects, &rooted, live_bytes, max_bytes, |path| {
1723 if dry_run {
1724 virtually_removed.insert(path.to_path_buf());
1725 Ok(Removal::Removed)
1726 } else {
1727 remove(path)
1728 }
1729 })?;
1730 live_bytes = evicted.remaining_bytes;
1731 outcome.removed_objects += evicted.removed_objects;
1732 outcome.removed_bytes += evicted.removed_bytes;
1733 }
1734
1735 let cas = LocalCas::new(store);
1740 for entry in &results {
1741 if action_result_is_dangling(&cas, &entry.path, &virtually_removed)? {
1742 match if dry_run {
1743 Removal::Removed
1744 } else {
1745 remove(&entry.path)?
1746 } {
1747 Removal::Removed => {
1748 live_bytes = live_bytes.saturating_sub(entry.size);
1749 outcome.removed_action_results += 1;
1750 outcome.removed_bytes += entry.size;
1751 }
1752 Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
1753 Removal::Blocked => {}
1754 }
1755 }
1756 }
1757
1758 let sessions = prune_sessions(store, dry_run)?;
1759 outcome.removed_session_streams += sessions.removed_streams;
1760 outcome.removed_bytes += sessions.removed_bytes;
1761 prune_import_staging(store, dry_run);
1762
1763 outcome.remaining_bytes = live_bytes;
1764 Ok(outcome)
1765}
1766
1767fn prune_import_staging(store: &Path, dry_run: bool) {
1779 if dry_run {
1780 return;
1781 }
1782 let root = store.join(IMPORT_STAGING_DIR);
1783 let Ok(entries) = read_dir_or_empty(&root) else {
1784 return;
1785 };
1786 for entry in entries {
1787 let Ok(metadata) = entry.metadata() else {
1788 continue;
1789 };
1790 let abandoned = metadata
1791 .modified()
1792 .ok()
1793 .and_then(|modified| SystemTime::now().duration_since(modified).ok())
1794 .is_some_and(|age| age >= IMPORT_STAGING_RETENTION);
1795 if !abandoned {
1796 continue;
1797 }
1798 let path = entry.path();
1799 if !metadata.is_dir() {
1800 if path
1803 .extension()
1804 .is_some_and(|extension| extension == "lock")
1805 && !claimed_staging_path(&path).exists()
1806 {
1807 let _ = std::fs::remove_file(&path);
1808 }
1809 continue;
1810 }
1811 let lock_path = import_staging_lock_path(&path);
1817 let Ok(mut lock) = fslock::LockFile::open(&lock_path) else {
1818 continue;
1819 };
1820 if !matches!(lock.try_lock(), Ok(true)) {
1821 continue;
1822 }
1823 if let Err(error) = std::fs::remove_dir_all(&path) {
1824 log::debug!(
1825 "could not remove abandoned import staging {}: {error}",
1826 path.display()
1827 );
1828 continue;
1829 }
1830 drop(lock);
1831 let _ = std::fs::remove_file(&lock_path);
1832 }
1833}
1834
1835fn claimed_staging_path(lock: &Path) -> PathBuf {
1837 lock.with_extension("")
1838}
1839
1840fn import_staging_lock_path(staging: &Path) -> PathBuf {
1845 let mut name = staging.as_os_str().to_os_string();
1846 name.push(".lock");
1847 PathBuf::from(name)
1848}
1849
1850struct StagingClaim {
1856 lock: Option<fslock::LockFile>,
1857 path: PathBuf,
1858}
1859
1860impl Drop for StagingClaim {
1861 fn drop(&mut self) {
1862 drop(self.lock.take());
1865 let _ = std::fs::remove_file(&self.path);
1866 }
1867}
1868
1869fn lock_import_staging(staging: &Path) -> Result<StagingClaim> {
1871 let path = import_staging_lock_path(staging);
1872 let mut lock = fslock::LockFile::open(&path)
1873 .wrap_err_with(|| format!("failed to open {}", path.display()))?;
1874 lock.lock()?;
1875 Ok(StagingClaim {
1876 lock: Some(lock),
1877 path,
1878 })
1879}
1880
1881#[derive(Debug, Default, PartialEq, Eq)]
1882struct SessionPrune {
1883 removed_streams: u64,
1884 removed_bytes: u64,
1885}
1886
1887fn prune_sessions(store: &Path, dry_run: bool) -> Result<SessionPrune> {
1888 let mut prune = SessionPrune::default();
1889 let ids = events::session_ids(store);
1890 let surplus = ids.len().saturating_sub(MAX_SESSIONS);
1891 for (index, id) in ids.iter().enumerate() {
1892 let paths = events::session_paths(store, id);
1893 let metadata = match std::fs::metadata(&paths.events) {
1894 Ok(metadata) => metadata,
1895 Err(_) => continue,
1896 };
1897 let stale = metadata
1898 .modified()
1899 .ok()
1900 .and_then(|at| SystemTime::now().duration_since(at).ok())
1901 .is_some_and(|age| age > SESSION_RETENTION);
1902 if index >= surplus && !stale {
1903 continue;
1904 }
1905 if events::session_is_live(store, id) {
1906 continue;
1907 }
1908 if dry_run {
1909 prune.removed_streams += 1;
1910 prune.removed_bytes += metadata.len();
1911 continue;
1912 }
1913 if matches!(remove(&paths.events)?, Removal::Removed) {
1914 prune.removed_streams += 1;
1915 prune.removed_bytes += metadata.len();
1916 let _ = std::fs::remove_file(&paths.lock);
1917 }
1918 }
1919 if !dry_run {
1920 for lock in events::orphaned_locks(store) {
1921 let _ = std::fs::remove_file(lock);
1922 }
1923 }
1924 Ok(prune)
1925}
1926
1927pub fn sweep_if_due(store: &Path, max_bytes: u64, interval: Duration) -> Result<Option<GcOutcome>> {
1933 if !claim_sweep(store, interval)? {
1934 return Ok(None);
1935 }
1936 gc(store, max_bytes).map(Some)
1937}
1938
1939pub fn claim_sweep(store: &Path, interval: Duration) -> Result<bool> {
1945 let lock_path = store.join(SWEEP_LOCK);
1946 std::fs::create_dir_all(lock_path.parent().expect("sweep lock has a parent"))?;
1947 let mut lock = fslock::LockFile::open(&lock_path)?;
1948 lock.lock()?;
1949
1950 let stamp = store.join(SWEEP_STAMP);
1951 if sweep_stamp_is_fresh(&stamp, interval) {
1952 return Ok(false);
1953 }
1954 let stamp = std::fs::OpenOptions::new()
1955 .create(true)
1956 .write(true)
1957 .truncate(false)
1958 .open(&stamp)?;
1959 stamp.set_modified(SystemTime::now())?;
1960 Ok(true)
1961}
1962
1963pub fn sweep_is_due(store: &Path, interval: Duration) -> bool {
1971 !sweep_stamp_is_fresh(&store.join(SWEEP_STAMP), interval)
1972}
1973
1974fn sweep_stamp_is_fresh(stamp: &Path, interval: Duration) -> bool {
1975 std::fs::metadata(stamp)
1976 .and_then(|metadata| metadata.modified())
1977 .ok()
1978 .and_then(|modified| modified.elapsed().ok())
1979 .is_some_and(|since| since < interval)
1980}
1981
1982struct CheckoutScan {
1984 live_identities: BTreeSet<String>,
1985 live_records: u64,
1986 stale_records: Vec<PathBuf>,
1987}
1988
1989fn scan_checkouts(store: &Path) -> Result<CheckoutScan> {
1990 let root = store.join(CHECKOUTS_DIR);
1991 let mut scan = CheckoutScan {
1992 live_identities: BTreeSet::new(),
1993 live_records: 0,
1994 stale_records: Vec::new(),
1995 };
1996 let listing = match std::fs::read_dir(&root) {
1997 Ok(listing) => listing,
1998 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(scan),
1999 Err(error) => {
2000 return Err(error).wrap_err_with(|| format!("failed to read {}", root.display()));
2001 }
2002 };
2003 for entry in listing {
2004 let entry = entry?;
2005 let name = entry.file_name().to_string_lossy().into_owned();
2006 if !is_task_identity(&name) || !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
2012 continue;
2013 }
2014 for record in walk_files(&entry.path())? {
2015 match read_checkout_record(&record.path) {
2016 Some(checkout) if claim_is_live(store, &checkout) => {
2017 scan.live_identities.insert(name.clone());
2018 scan.live_records += 1;
2019 }
2020 Some(_) => scan.stale_records.push(record.path),
2021 None => {}
2024 }
2025 }
2026 }
2027 Ok(scan)
2028}
2029
2030fn read_checkout_record(path: &Path) -> Option<CheckoutRecord> {
2031 let bytes = std::fs::read(path).ok()?;
2032 let record = serde_json::from_slice::<CheckoutRecord>(&bytes).ok()?;
2033 (record.version == CHECKOUT_RECORD_VERSION).then_some(record)
2034}
2035
2036fn grouped_receipt_actions(store: &Path) -> Result<BTreeSet<CacheDigest>> {
2038 let root = store.join(BUILD_RECEIPTS_DIR).join("groups");
2039 Ok(walk_files(&root)?
2040 .into_iter()
2041 .filter_map(|entry| read_build_receipt(&entry.path))
2042 .flat_map(|receipt| receipt.predictions.into_iter())
2043 .map(|prediction| prediction.action)
2044 .collect())
2045}
2046
2047fn claim_is_live(store: &Path, record: &CheckoutRecord) -> bool {
2050 checkout_is_live_on(store, &record.workspace_root) && !claim_has_expired(record)
2051}
2052
2053fn claim_has_expired(record: &CheckoutRecord) -> bool {
2054 let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
2055 return false;
2056 };
2057 now.as_secs()
2058 .saturating_sub(record.updated_secs)
2059 .gt(&CHECKOUT_RETENTION.as_secs())
2060}
2061
2062pub fn checkout_is_live_on(store: &Path, workspace_root: &Path) -> bool {
2075 if !matches!(workspace_root.try_exists(), Ok(false)) {
2076 return true;
2077 }
2078
2079 let Ok(store_metadata) = std::fs::metadata(store) else {
2080 return true;
2081 };
2082 for ancestor in workspace_root.ancestors().skip(1) {
2083 match std::fs::metadata(ancestor) {
2084 Ok(ancestor_metadata) => {
2085 return !same_filesystem(store, &store_metadata, ancestor, &ancestor_metadata);
2086 }
2087 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2088 Err(_) => return true,
2089 }
2090 }
2091 true
2092}
2093
2094pub fn checkout_is_live(workspace_root: &Path) -> bool {
2100 let Some(parent) = workspace_root.parent() else {
2101 return true;
2102 };
2103 checkout_is_live_on(parent, workspace_root)
2104}
2105
2106pub fn same_filesystem_paths(a: &Path, b: &Path) -> bool {
2112 let (Ok(a_metadata), Ok(b_metadata)) = (std::fs::metadata(a), std::fs::metadata(b)) else {
2113 return false;
2114 };
2115 same_filesystem(a, &a_metadata, b, &b_metadata)
2116}
2117
2118#[cfg(unix)]
2119fn same_filesystem(
2120 _a_path: &Path,
2121 a: &std::fs::Metadata,
2122 _b_path: &Path,
2123 b: &std::fs::Metadata,
2124) -> bool {
2125 use std::os::unix::fs::MetadataExt as _;
2126 a.dev() == b.dev()
2127}
2128
2129#[cfg(windows)]
2130fn same_filesystem(
2131 a_path: &Path,
2132 _a: &std::fs::Metadata,
2133 b_path: &Path,
2134 _b: &std::fs::Metadata,
2135) -> bool {
2136 windows_volume_name(a_path).is_some_and(|volume| windows_volume_name(b_path) == Some(volume))
2137}
2138
2139#[cfg(windows)]
2140fn windows_volume_name(path: &Path) -> Option<Vec<u16>> {
2141 use std::os::windows::ffi::OsStrExt as _;
2142 use windows_sys::Win32::Storage::FileSystem::{
2143 GetVolumeNameForVolumeMountPointW, GetVolumePathNameW,
2144 };
2145
2146 let path = path
2147 .as_os_str()
2148 .encode_wide()
2149 .chain(Some(0))
2150 .collect::<Vec<_>>();
2151 let mut mount = vec![0_u16; 261];
2155 if unsafe { GetVolumePathNameW(path.as_ptr(), mount.as_mut_ptr(), mount.len() as u32) } == 0 {
2158 return None;
2159 }
2160 let mut volume = vec![0_u16; 261];
2161 if unsafe {
2164 GetVolumeNameForVolumeMountPointW(mount.as_ptr(), volume.as_mut_ptr(), volume.len() as u32)
2165 } == 0
2166 {
2167 return None;
2168 }
2169 volume.truncate(volume.iter().position(|unit| *unit == 0)?);
2170 Some(volume)
2171}
2172
2173#[cfg(not(any(unix, windows)))]
2174fn same_filesystem(
2175 _a_path: &Path,
2176 _a: &std::fs::Metadata,
2177 _b_path: &Path,
2178 _b: &std::fs::Metadata,
2179) -> bool {
2180 false
2181}
2182
2183fn rooted_objects(store: &Path, identities: &BTreeSet<String>) -> Result<HashSet<PathBuf>> {
2191 let mut actions = BTreeSet::new();
2192 for identity in identities {
2193 let manifest_actions = match task_manifest_actions(store, identity) {
2196 Ok(actions) => actions,
2197 Err(error) => {
2198 log::debug!("could not read the manifest for {identity}: {error}");
2199 continue;
2200 }
2201 };
2202 actions.extend(manifest_actions);
2203 }
2204 Ok(rooted_action_objects(store, actions))
2205}
2206
2207fn rooted_action_objects(
2209 store: &Path,
2210 actions: impl IntoIterator<Item = CacheDigest>,
2211) -> HashSet<PathBuf> {
2212 let cas = LocalCas::new(store);
2213 let mut rooted = HashSet::new();
2214 let mut visited = BTreeSet::new();
2215 for action in actions {
2216 let Some(result) = read_action_result(store, &action) else {
2217 continue;
2218 };
2219 root_digest(&cas, &mut rooted, &result.action);
2220 if let Some(metadata) = &result.metadata {
2221 root_digest(&cas, &mut rooted, metadata);
2222 if let Some(rustc) = read_rustc_metadata(&cas, metadata) {
2229 root_digest(&cas, &mut rooted, &rustc.stdout);
2230 root_digest(&cas, &mut rooted, &rustc.stderr);
2231 }
2232 }
2233 if let Some(output_root) = &result.output_root {
2234 root_digest(&cas, &mut rooted, output_root);
2235 root_tree(&cas, output_root, &mut rooted, &mut visited);
2236 }
2237 }
2238 rooted
2239}
2240
2241fn read_action_result(store: &Path, action: &CacheDigest) -> Option<RemoteActionResult> {
2242 let path = mbx_cache_core::LocalActionCache::new(store)
2243 .path_for(action)
2244 .ok()?;
2245 let bytes = std::fs::read(path).ok()?;
2246 serde_json::from_slice(&bytes).ok()
2247}
2248
2249fn root_tree(
2250 cas: &LocalCas,
2251 output_root: &CacheDigest,
2252 rooted: &mut HashSet<PathBuf>,
2253 visited: &mut BTreeSet<CacheDigest>,
2254) {
2255 let mut pending = vec![output_root.clone()];
2256 while let Some(digest) = pending.pop() {
2257 if !visited.insert(digest.clone()) {
2258 continue;
2259 }
2260 let Some(directory) = read_directory(cas, &digest) else {
2261 continue;
2262 };
2263 for file in &directory.files {
2264 root_digest(cas, rooted, &file.digest);
2265 }
2266 for child in &directory.directories {
2267 root_digest(cas, rooted, &child.digest);
2268 pending.push(child.digest.clone());
2269 }
2270 }
2271}
2272
2273fn read_rustc_metadata(cas: &LocalCas, digest: &CacheDigest) -> Option<RustcMetadata> {
2274 let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2275 serde_json::from_slice(&bytes).ok()
2276}
2277
2278fn read_directory(cas: &LocalCas, digest: &CacheDigest) -> Option<CacheDirectory> {
2279 let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2280 serde_json::from_slice(&bytes).ok()
2281}
2282
2283fn root_digest(cas: &LocalCas, rooted: &mut HashSet<PathBuf>, digest: &CacheDigest) {
2284 if let Ok(path) = cas.path_for(digest) {
2285 rooted.insert(path);
2286 }
2287}
2288
2289#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2290enum Removal {
2291 Removed,
2292 Missing,
2293 Blocked,
2294}
2295
2296fn remove(path: &Path) -> Result<Removal> {
2297 match std::fs::remove_file(path) {
2298 Ok(()) => Ok(Removal::Removed),
2299 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Removal::Missing),
2301 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2306 log::debug!("could not evict {}: {error}", path.display());
2307 Ok(Removal::Blocked)
2308 }
2309 Err(error) => Err(error).wrap_err_with(|| format!("failed to evict {}", path.display())),
2310 }
2311}
2312
2313#[derive(Debug, Default, PartialEq, Eq)]
2314struct ObjectEvictions {
2315 removed_objects: u64,
2316 removed_bytes: u64,
2317 remaining_bytes: u64,
2318}
2319
2320fn evict_objects(
2323 objects: &[Entry],
2324 rooted: &HashSet<PathBuf>,
2325 mut live_bytes: u64,
2326 max_bytes: u64,
2327 mut remove_file: impl FnMut(&Path) -> Result<Removal>,
2328) -> Result<ObjectEvictions> {
2329 let mut outcome = ObjectEvictions::default();
2330 let mut blocked_unrooted = false;
2331 for entry in objects {
2332 if live_bytes <= max_bytes {
2333 break;
2334 }
2335 let is_rooted = rooted.contains(&entry.path);
2336 if is_rooted && blocked_unrooted {
2337 break;
2341 }
2342 match remove_file(&entry.path)? {
2343 Removal::Removed => {
2344 live_bytes = live_bytes.saturating_sub(entry.size);
2345 outcome.removed_objects += 1;
2346 outcome.removed_bytes += entry.size;
2347 }
2348 Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
2349 Removal::Blocked if !is_rooted => blocked_unrooted = true,
2350 Removal::Blocked => {}
2351 }
2352 }
2353 outcome.remaining_bytes = live_bytes;
2354 Ok(outcome)
2355}
2356
2357fn action_result_is_dangling(
2375 cas: &LocalCas,
2376 path: &Path,
2377 virtually_removed: &HashSet<PathBuf>,
2378) -> Result<bool> {
2379 let bytes = match std::fs::read(path) {
2380 Ok(bytes) => bytes,
2381 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
2382 Err(error) => {
2383 return Err(error).wrap_err_with(|| format!("failed to read {}", path.display()));
2384 }
2385 };
2386 let Ok(result) = serde_json::from_slice::<RemoteActionResult>(&bytes) else {
2388 return Ok(false);
2389 };
2390 for digest in [
2391 Some(&result.action),
2392 result.metadata.as_ref(),
2393 result.output_root.as_ref(),
2394 ]
2395 .into_iter()
2396 .flatten()
2397 {
2398 match cas.path_for(digest) {
2399 Ok(path) => {
2400 if virtually_removed.contains(&path) || !path.exists() {
2401 return Ok(true);
2402 }
2403 }
2404 Err(_) => return Ok(true),
2406 }
2407 }
2408 Ok(false)
2409}
2410
2411struct Entry {
2412 path: PathBuf,
2413 size: u64,
2414 used: SystemTime,
2415}
2416
2417fn walk_files(root: &Path) -> Result<Vec<Entry>> {
2418 let mut entries = Vec::new();
2419 let mut pending = vec![root.to_path_buf()];
2420 while let Some(directory) = pending.pop() {
2421 let listing = match std::fs::read_dir(&directory) {
2422 Ok(listing) => listing,
2423 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2424 Err(error) => {
2425 return Err(error)
2426 .wrap_err_with(|| format!("failed to read {}", directory.display()));
2427 }
2428 };
2429 for entry in listing {
2430 let entry = entry?;
2431 let metadata = match entry.metadata() {
2432 Ok(metadata) => metadata,
2433 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2435 Err(error) => return Err(error.into()),
2436 };
2437 if metadata.is_dir() {
2438 pending.push(entry.path());
2439 } else if metadata.is_file() {
2440 let used = metadata
2441 .accessed()
2442 .or_else(|_| metadata.modified())
2443 .unwrap_or(SystemTime::UNIX_EPOCH);
2444 entries.push(Entry {
2445 path: entry.path(),
2446 size: metadata.len(),
2447 used,
2448 });
2449 }
2450 }
2451 }
2452 Ok(entries)
2453}
2454
2455fn read_dir_or_empty(root: &Path) -> Result<Vec<std::fs::DirEntry>> {
2456 match std::fs::read_dir(root) {
2457 Ok(entries) => entries
2458 .collect::<std::io::Result<Vec<_>>>()
2459 .map_err(Into::into),
2460 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
2461 Err(error) => Err(error).wrap_err_with(|| format!("failed to read {}", root.display())),
2462 }
2463}
2464
2465fn digest_from_path(path: &Path, action_result: bool) -> Option<CacheDigest> {
2466 let name = path.file_name()?.to_str()?;
2467 let name = if action_result {
2468 name.strip_suffix(".json")?
2469 } else {
2470 name
2471 };
2472 let (hash, size) = name.rsplit_once('-')?;
2473 let algorithm = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
2474 let digest = CacheDigest {
2475 algorithm,
2476 hash: hash.to_string(),
2477 size: size.parse().ok()?,
2478 };
2479 digest.validate().ok()?;
2480 Some(digest)
2481}
2482
2483fn addressed_digest(store: &Path, path: &Path, action_result: bool) -> Option<CacheDigest> {
2484 let digest = digest_from_path(path, action_result)?;
2485 let canonical = if action_result {
2486 mbx_cache_core::LocalActionCache::new(store)
2487 .path_for(&digest)
2488 .ok()?
2489 } else {
2490 LocalCas::new(store).path_for(&digest).ok()?
2491 };
2492 (canonical == path).then_some(digest)
2493}
2494
2495fn cached_tree_bytes(cache: &mut BTreeMap<PathBuf, u64>, root: &Path) -> u64 {
2496 *cache
2497 .entry(root.to_path_buf())
2498 .or_insert_with(|| tree_bytes(root))
2499}
2500
2501fn tree_bytes(root: &Path) -> u64 {
2502 let Ok(root_metadata) = std::fs::metadata(root) else {
2503 return 0;
2504 };
2505 if root_metadata.is_file() {
2506 return root_metadata.len();
2507 }
2508 let Ok(entries) = std::fs::read_dir(root) else {
2509 return 0;
2510 };
2511 let mut bytes = 0_u64;
2512 let mut pending = entries
2513 .filter_map(std::result::Result::ok)
2514 .map(|entry| entry.path())
2515 .collect::<Vec<_>>();
2516 while let Some(path) = pending.pop() {
2517 let Ok(metadata) = std::fs::symlink_metadata(&path) else {
2518 continue;
2519 };
2520 if metadata.is_file() {
2521 bytes = bytes.saturating_add(metadata.len());
2522 } else if metadata.is_dir()
2523 && let Ok(entries) = std::fs::read_dir(path)
2524 {
2525 pending.extend(
2526 entries
2527 .filter_map(std::result::Result::ok)
2528 .map(|entry| entry.path()),
2529 );
2530 }
2531 }
2532 bytes
2533}
2534
2535#[cfg(test)]
2536#[path = "store_tests.rs"]
2537mod tests;
2538
2539fn write_advisory(path: &Path, contents: &[u8]) -> Result<()> {
2542 let parent = path
2543 .parent()
2544 .filter(|parent| !parent.as_os_str().is_empty())
2545 .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
2546 std::fs::create_dir_all(parent)
2547 .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
2548 let mut temporary = tempfile::Builder::new()
2549 .prefix(".mbx-")
2550 .tempfile_in(parent)?;
2551 use std::io::Write as _;
2552 temporary.write_all(contents)?;
2553 temporary
2554 .persist(path)
2555 .map_err(|error| error.error)
2556 .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
2557 Ok(())
2558}
2559
2560fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
2561 let parent = path
2562 .parent()
2563 .filter(|parent| !parent.as_os_str().is_empty())
2564 .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
2565 std::fs::create_dir_all(parent)
2566 .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
2567 let mut temporary = tempfile::Builder::new()
2568 .prefix(".mbx-")
2569 .tempfile_in(parent)?;
2570 use std::io::Write as _;
2571 temporary.write_all(contents)?;
2572 temporary.as_file_mut().sync_all()?;
2573 temporary
2574 .persist(path)
2575 .map_err(|error| error.error)
2576 .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
2577 Ok(())
2578}