1mod events;
14
15use eyre::{Context, Result};
16use mbx_cache_core::{
17 ActionPrediction, ActionSlot, CacheDigest, CacheDirectory, LocalCas, RemoteActionResult,
18 RustcMetadata, TaskActionManifest, is_task_identity, merge_task_action_predictions,
19 task_manifest_actions,
20};
21use serde::{Deserialize, Serialize};
22use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
23use std::path::{Component, Path, PathBuf};
24use std::sync::atomic::{AtomicUsize, Ordering};
25use std::sync::{Arc, Mutex};
26use std::time::{Duration, SystemTime, UNIX_EPOCH};
27
28const CAS_DIR: &str = "cas/v1";
29const ACTION_RESULTS_DIR: &str = "action-results/v1";
30const TASK_MANIFESTS_DIR: &str = "task-manifests/v1";
31const CHECKOUTS_DIR: &str = "checkouts/v1";
32const SWEEP_STAMP: &str = "gc/v1/last-sweep";
33const SWEEP_LOCK: &str = "gc/v1/sweep.lock";
34const CHECKOUT_RECORD_VERSION: u8 = 1;
35const BUILD_RECEIPTS_DIR: &str = "build-receipts/v1";
36const BUILD_RECEIPT_VERSION: u8 = 1;
37const IMPORT_STAGING_DIR: &str = "import-staging";
38const EXPORT_MANIFEST: &str = "mbx-cache-export-v1.json";
39const EXPORT_VERSION: u8 = 2;
40const LEGACY_EXPORT_VERSION: u8 = 1;
41
42const IMPORT_STAGING_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
43const SESSION_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
44
45const TASK_MANIFEST_GRACE: Duration = Duration::from_secs(24 * 60 * 60);
54const MAX_SESSIONS: usize = 256;
55
56const CHECKOUT_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60);
66
67#[derive(Debug, Default, PartialEq, Eq)]
68pub struct StoreStats {
69 pub objects: u64,
70 pub object_bytes: u64,
71 pub action_results: u64,
72 pub action_result_bytes: u64,
73 pub live_checkouts: u64,
74 pub stale_checkouts: u64,
79}
80
81impl StoreStats {
82 pub fn total_bytes(&self) -> u64 {
83 self.object_bytes.saturating_add(self.action_result_bytes)
84 }
85}
86
87#[derive(Debug, Default, PartialEq, Eq)]
88pub struct GcOutcome {
89 pub removed_objects: u64,
90 pub removed_action_results: u64,
91 pub removed_checkout_records: u64,
92 pub removed_task_manifests: u64,
96 pub removed_session_streams: u64,
99 pub removed_bytes: u64,
100 pub remaining_bytes: u64,
101}
102
103#[derive(Debug, PartialEq, Eq)]
104pub struct ProjectUsage {
105 pub workspace_root: PathBuf,
106 pub identities: u64,
107 pub action_bytes: u64,
108 pub target_bytes: u64,
109 pub live: bool,
110}
111
112#[derive(Debug, PartialEq, Eq)]
113pub struct LargestEntry {
114 pub kind: &'static str,
115 pub path: PathBuf,
116 pub bytes: u64,
117}
118
119#[derive(Debug, Default, PartialEq, Eq)]
120pub struct VerifyOutcome {
121 pub checked_objects: u64,
122 pub checked_action_results: u64,
123 pub problems: Vec<PathBuf>,
124}
125
126#[derive(Debug, Default, PartialEq, Eq)]
127pub struct RemoveProjectOutcome {
128 pub removed_checkout_records: u64,
129}
130
131#[derive(Debug, Default, PartialEq, Eq)]
132pub struct TransferOutcome {
133 pub actions: u64,
134 pub objects: u64,
135 pub bytes: u64,
136}
137
138#[derive(Debug)]
140#[non_exhaustive]
141pub struct ImportOutcome {
142 pub transfer: TransferOutcome,
143 pub attachments: BTreeMap<String, CacheDigest>,
144 pub tasks: Vec<TaskActionManifest>,
146}
147
148#[derive(Debug, Default)]
150pub struct ExportAdditions {
151 pub attachments: BTreeMap<String, CacheDigest>,
153 pub objects: BTreeSet<CacheDigest>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
159pub struct WorkspaceTarget {
160 pub workspace_root: PathBuf,
161 pub target_dir: PathBuf,
162}
163
164#[derive(Debug, Serialize, Deserialize)]
165#[serde(deny_unknown_fields)]
166struct ExportManifest {
167 version: u8,
168 tasks: Vec<TaskActionManifest>,
169 actions: Vec<CacheDigest>,
170 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
171 attachments: BTreeMap<String, CacheDigest>,
172 #[serde(default, skip_serializing_if = "Vec::is_empty")]
173 objects: Vec<CacheDigest>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(deny_unknown_fields)]
179struct BuildReceipt {
180 version: u8,
181 workspace_root: PathBuf,
182 identity: String,
183 completed_nanos: u64,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 group: Option<String>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 restored: Option<String>,
189 predictions: Vec<ActionPrediction>,
190}
191
192pub fn record_build_receipt(
194 store: &Path,
195 run: &str,
196 identity: &str,
197 workspace_root: &Path,
198 group: Option<&str>,
199 predictions: Vec<ActionPrediction>,
200) -> Result<()> {
201 if !is_task_identity(run) || !is_task_identity(identity) {
202 eyre::bail!("invalid build receipt identity");
203 }
204 if !(TaskActionManifest {
205 version: 1,
206 task: identity.to_owned(),
207 predictions: predictions.clone(),
208 })
209 .validate()
210 {
211 eyre::bail!("invalid build receipt prediction");
212 }
213 if let Some(group) = group {
214 validate_export_group(group)?;
215 }
216 if group.is_none()
221 && read_build_receipt(&latest_receipt_path(store, workspace_root)).is_some_and(|latest| {
222 latest.identity == identity
223 && latest.workspace_root == workspace_root
224 && latest.group.is_none()
225 && latest.predictions == predictions
226 })
227 {
228 return Ok(());
229 }
230 let mut lock = lock_receipts(store, workspace_root)?;
231 lock.lock()?;
232 let (predictions, restored) = if group.is_some() {
233 carry_restored_predictions(store, workspace_root, identity, predictions)
234 } else {
235 (predictions, None)
236 };
237 let completed_nanos = SystemTime::now()
238 .duration_since(UNIX_EPOCH)
239 .map(|elapsed| elapsed.as_nanos().try_into().unwrap_or(u64::MAX))
240 .unwrap_or_default();
241 let receipt = BuildReceipt {
242 version: BUILD_RECEIPT_VERSION,
243 workspace_root: workspace_root.to_path_buf(),
244 identity: identity.to_owned(),
245 completed_nanos,
246 group: group.map(str::to_owned),
247 restored,
248 predictions,
249 };
250 let bytes = serde_json::to_vec(&receipt)?;
251 write_atomic(&latest_receipt_path(store, workspace_root), &bytes)?;
252 if let Some(group) = group {
253 write_atomic(&group_receipt_path(store, group, run), &bytes)?;
254 }
255 Ok(())
256}
257
258fn lock_receipts(store: &Path, workspace_root: &Path) -> Result<fslock::LockFile> {
259 let lock_path = store
260 .join(BUILD_RECEIPTS_DIR)
261 .join("locks")
262 .join(format!("{}.lock", workspace_key(workspace_root)));
263 std::fs::create_dir_all(lock_path.parent().expect("receipt lock has a parent"))?;
264 Ok(fslock::LockFile::open(&lock_path)?)
265}
266
267#[derive(Debug, Serialize, Deserialize)]
269#[serde(deny_unknown_fields)]
270struct RestoredPredictions {
271 version: u8,
272 id: String,
275 workspace_root: PathBuf,
276 tasks: Vec<TaskActionManifest>,
277}
278
279pub fn record_restored_predictions(
288 store: &Path,
289 workspace_root: &Path,
290 tasks: &[TaskActionManifest],
291) -> Result<()> {
292 let nanos = SystemTime::now()
293 .duration_since(UNIX_EPOCH)
294 .map(|elapsed| elapsed.as_nanos())
295 .unwrap_or_default();
296 let id = CacheDigest::blake3(
297 format!(
298 "{}\0{nanos}\0{}",
299 std::process::id(),
300 workspace_root.display()
301 )
302 .as_bytes(),
303 )
304 .hash;
305 let bytes = serde_json::to_vec(&RestoredPredictions {
306 version: 1,
307 id,
308 workspace_root: workspace_root.to_path_buf(),
309 tasks: tasks.to_vec(),
310 })?;
311 let mut lock = lock_receipts(store, workspace_root)?;
312 lock.lock()?;
313 write_atomic(&restored_predictions_path(store, workspace_root), &bytes)
314}
315
316pub fn clear_restored_predictions(store: &Path, workspace_root: &Path) -> Result<()> {
318 let mut lock = lock_receipts(store, workspace_root)?;
319 lock.lock()?;
320 match std::fs::remove_file(restored_predictions_path(store, workspace_root)) {
321 Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(error.into()),
322 _ => Ok(()),
323 }
324}
325
326pub fn restored_predictions_pending(store: &Path) -> bool {
328 walk_files(&store.join(BUILD_RECEIPTS_DIR).join("restored"))
329 .is_ok_and(|files| !files.is_empty())
330}
331
332fn restored_predictions_path(store: &Path, workspace_root: &Path) -> PathBuf {
333 store
334 .join(BUILD_RECEIPTS_DIR)
335 .join("restored")
336 .join(format!("{}.json", workspace_key(workspace_root)))
337}
338
339fn read_restored_predictions(store: &Path, workspace_root: &Path) -> Option<RestoredPredictions> {
340 std::fs::read(restored_predictions_path(store, workspace_root))
341 .ok()
342 .and_then(|bytes| serde_json::from_slice::<RestoredPredictions>(&bytes).ok())
343 .filter(|restored| restored.version == 1 && restored.workspace_root == workspace_root)
344}
345
346fn carry_restored_predictions(
356 store: &Path,
357 workspace_root: &Path,
358 identity: &str,
359 predictions: Vec<ActionPrediction>,
360) -> (Vec<ActionPrediction>, Option<String>) {
361 let Some(mut restored) = read_restored_predictions(store, workspace_root) else {
362 return (predictions, None);
363 };
364 let Some(task) = restored.tasks.iter_mut().find(|task| task.task == identity) else {
365 return (predictions, None);
366 };
367 let action_cache = mbx_cache_core::LocalActionCache::new(store);
368 let carried = task
369 .predictions
370 .iter()
371 .filter(|prediction| {
372 action_cache
373 .path_for(&prediction.action)
374 .is_ok_and(|path| path.is_file())
375 })
376 .cloned()
377 .collect::<Vec<_>>();
378 let protected = predictions
379 .iter()
380 .map(|prediction| prediction.invocation.clone())
381 .collect::<BTreeSet<_>>();
382 let Ok(combined) = merge_task_action_predictions(carried, predictions.clone(), &protected)
383 else {
384 return (predictions, None);
385 };
386 let valid = TaskActionManifest {
387 version: 1,
388 task: identity.to_owned(),
389 predictions: combined.clone(),
390 }
391 .validate();
392 if !valid {
393 return (predictions, None);
394 }
395 task.predictions = combined.clone();
396 let id = restored.id.clone();
397 if let Ok(bytes) = serde_json::to_vec(&restored) {
398 let _ = write_atomic(&restored_predictions_path(store, workspace_root), &bytes);
399 }
400 (combined, Some(id))
401}
402
403fn workspace_key(workspace_root: &Path) -> String {
404 CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash
405}
406
407fn latest_receipt_path(store: &Path, workspace_root: &Path) -> PathBuf {
408 store
409 .join(BUILD_RECEIPTS_DIR)
410 .join("checkouts")
411 .join(format!("{}.json", workspace_key(workspace_root)))
412}
413
414fn group_receipt_path(store: &Path, group: &str, run: &str) -> PathBuf {
415 store
416 .join(BUILD_RECEIPTS_DIR)
417 .join("groups")
418 .join(group_key(group))
419 .join(format!("{run}.json"))
420}
421
422fn group_key(group: &str) -> String {
423 CacheDigest::blake3(group.as_bytes()).hash
424}
425
426fn read_build_receipt(path: &Path) -> Option<BuildReceipt> {
427 let bytes = std::fs::read(path).ok()?;
428 let receipt = serde_json::from_slice::<BuildReceipt>(&bytes).ok()?;
429 let valid = TaskActionManifest {
430 version: 1,
431 task: receipt.identity.clone(),
432 predictions: receipt.predictions.clone(),
433 }
434 .validate();
435 (receipt.version == BUILD_RECEIPT_VERSION && valid).then_some(receipt)
436}
437
438fn validate_export_group(group: &str) -> Result<()> {
439 if group.is_empty() || group.len() > 256 || group.chars().any(char::is_control) {
440 eyre::bail!("invalid build export group");
441 }
442 Ok(())
443}
444
445#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
453pub enum ExportForm {
454 #[default]
456 Tar,
457 Directory,
459}
460
461impl std::str::FromStr for ExportForm {
462 type Err = eyre::Report;
463
464 fn from_str(text: &str) -> Result<Self> {
465 match text {
466 "tar" => Ok(Self::Tar),
467 "directory" => Ok(Self::Directory),
468 other => eyre::bail!("unknown export format {other:?}; expected tar or directory"),
469 }
470 }
471}
472
473pub fn export_checkout(
475 store: &Path,
476 workspace_root: &Path,
477 archive: &Path,
478) -> Result<TransferOutcome> {
479 let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
480 .filter(|receipt| receipt.workspace_root == workspace_root)
481 .ok_or_else(|| {
482 eyre::eyre!(
483 "no completed mbx build is recorded for {}",
484 workspace_root.display()
485 )
486 })?;
487 export_receipts(
488 store,
489 vec![receipt],
490 archive,
491 ExportAdditions::default(),
492 ExportForm::Tar,
493 )
494}
495
496pub fn checkout_workspace_target(
498 store: &Path,
499 workspace_root: &Path,
500) -> Result<Option<WorkspaceTarget>> {
501 let Some(receipt) = read_build_receipt(&latest_receipt_path(store, workspace_root))
502 .filter(|receipt| receipt.workspace_root == workspace_root)
503 else {
504 return Ok(None);
505 };
506 Ok(workspace_target_for_receipt(store, &receipt))
507}
508
509pub fn group_workspace_targets(store: &Path, group: &str) -> Result<Vec<WorkspaceTarget>> {
511 validate_export_group(group)?;
512 let root = store
513 .join(BUILD_RECEIPTS_DIR)
514 .join("groups")
515 .join(group_key(group));
516 let targets = walk_files(&root)?
517 .into_iter()
518 .filter_map(|entry| read_build_receipt(&entry.path))
519 .filter(|receipt| receipt.group.as_deref() == Some(group))
520 .filter_map(|receipt| workspace_target_for_receipt(store, &receipt))
521 .collect::<BTreeSet<_>>();
522 Ok(targets.into_iter().collect())
523}
524
525fn workspace_target_for_receipt(store: &Path, receipt: &BuildReceipt) -> Option<WorkspaceTarget> {
526 let record = read_checkout_record(&checkout_record_path(
527 store,
528 &receipt.identity,
529 &receipt.workspace_root,
530 ))?;
531 (record.workspace_root == receipt.workspace_root && record.target_dir != record.workspace_root)
532 .then_some(WorkspaceTarget {
533 workspace_root: record.workspace_root,
534 target_dir: record.target_dir,
535 })
536}
537
538pub fn export_checkout_with(
540 store: &Path,
541 workspace_root: &Path,
542 archive: &Path,
543 additions: ExportAdditions,
544) -> Result<TransferOutcome> {
545 let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
546 .filter(|receipt| receipt.workspace_root == workspace_root)
547 .ok_or_else(|| {
548 eyre::eyre!(
549 "no completed mbx build is recorded for {}",
550 workspace_root.display()
551 )
552 })?;
553 export_receipts(store, vec![receipt], archive, additions, ExportForm::Tar)
554}
555
556pub fn export_checkout_as(
558 store: &Path,
559 workspace_root: &Path,
560 destination: &Path,
561 additions: ExportAdditions,
562 form: ExportForm,
563) -> Result<TransferOutcome> {
564 let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
565 .filter(|receipt| receipt.workspace_root == workspace_root)
566 .ok_or_else(|| {
567 eyre::eyre!(
568 "no completed mbx build is recorded for {}",
569 workspace_root.display()
570 )
571 })?;
572 export_receipts(store, vec![receipt], destination, additions, form)
573}
574
575pub fn export_group(store: &Path, group: &str, archive: &Path) -> Result<TransferOutcome> {
577 export_group_with(store, group, archive, ExportAdditions::default())
578}
579
580pub fn export_group_with(
582 store: &Path,
583 group: &str,
584 archive: &Path,
585 additions: ExportAdditions,
586) -> Result<TransferOutcome> {
587 export_group_as(store, group, archive, additions, ExportForm::Tar)
588}
589
590pub fn export_group_as(
592 store: &Path,
593 group: &str,
594 destination: &Path,
595 additions: ExportAdditions,
596 form: ExportForm,
597) -> Result<TransferOutcome> {
598 validate_export_group(group)?;
599 let root = store
600 .join(BUILD_RECEIPTS_DIR)
601 .join("groups")
602 .join(group_key(group));
603 let receipts = walk_files(&root)?
604 .into_iter()
605 .filter_map(|entry| read_build_receipt(&entry.path).map(|receipt| (entry.path, receipt)))
606 .filter(|(_, receipt)| receipt.group.as_deref() == Some(group))
607 .collect::<Vec<_>>();
608 if receipts.is_empty() {
609 eyre::bail!("no completed mbx builds are recorded for export group {group:?}");
610 }
611 let outcome = export_receipts(
612 store,
613 receipts
614 .iter()
615 .map(|(_, receipt)| receipt.clone())
616 .collect(),
617 destination,
618 additions,
619 form,
620 )?;
621 let mut carried = BTreeMap::<PathBuf, BTreeSet<String>>::new();
626 for (path, receipt) in receipts {
627 std::fs::remove_file(path)?;
628 let ids = carried.entry(receipt.workspace_root).or_default();
629 ids.extend(receipt.restored);
630 }
631 let _ = std::fs::remove_dir(root);
632 for (workspace_root, ids) in carried {
637 let Ok(mut lock) = lock_receipts(store, &workspace_root) else {
638 continue;
639 };
640 if lock.lock().is_err() {
641 continue;
642 }
643 if read_restored_predictions(store, &workspace_root)
644 .is_some_and(|restored| ids.contains(&restored.id))
645 {
646 let _ = std::fs::remove_file(restored_predictions_path(store, &workspace_root));
647 }
648 }
649 Ok(outcome)
650}
651
652fn export_receipts(
653 store: &Path,
654 mut receipts: Vec<BuildReceipt>,
655 archive: &Path,
656 additions: ExportAdditions,
657 form: ExportForm,
658) -> Result<TransferOutcome> {
659 receipts.sort_by(|left, right| {
660 left.completed_nanos
661 .cmp(&right.completed_nanos)
662 .then_with(|| {
663 fn key(prediction: &ActionPrediction) -> (&CacheDigest, &CacheDigest, &str, &str) {
666 (
667 &prediction.invocation,
668 &prediction.action,
669 &prediction.adapter,
670 &prediction.payload,
671 )
672 }
673 left.predictions
674 .iter()
675 .map(key)
676 .cmp(right.predictions.iter().map(key))
677 })
678 });
679 let mut actions = BTreeSet::new();
680 let mut tasks = BTreeMap::new();
681 for receipt in receipts {
682 actions.extend(
683 receipt
684 .predictions
685 .iter()
686 .map(|prediction| prediction.action.clone()),
687 );
688 tasks
691 .entry(receipt.identity)
692 .or_insert_with(Vec::new)
693 .extend(receipt.predictions);
694 }
695 let tasks = tasks
696 .into_iter()
697 .map(|(task, predictions)| {
698 let mut invocations = BTreeSet::new();
699 let mut predictions = predictions
700 .into_iter()
701 .rev()
702 .filter(|prediction| invocations.insert(prediction.invocation.clone()))
703 .collect::<Vec<_>>();
704 predictions.reverse();
705 TaskActionManifest {
706 version: 1,
707 task,
708 predictions,
709 }
710 })
711 .collect::<Vec<_>>();
712 if tasks.iter().any(|task| !task.validate()) {
713 eyre::bail!("combined export predictions exceed task manifest limits");
714 }
715 validate_export_additions(&additions)?;
716 let mut closure = strict_closure(store, &actions)?;
717 let cas = LocalCas::new(store);
718 for digest in &additions.objects {
719 require_object(&cas, &mut closure, digest)?;
720 }
721 verify_pending(&mut closure.pending).wrap_err("cache closure is incomplete or corrupt")?;
725 let parent = archive
726 .parent()
727 .filter(|parent| !parent.as_os_str().is_empty())
728 .unwrap_or_else(|| Path::new("."));
729 std::fs::create_dir_all(parent)?;
730 let manifest = serde_json::to_vec(&ExportManifest {
731 version: if additions.attachments.is_empty() && additions.objects.is_empty() {
732 LEGACY_EXPORT_VERSION
733 } else {
734 EXPORT_VERSION
735 },
736 tasks,
737 actions: actions.iter().cloned().collect(),
738 attachments: additions.attachments.clone(),
739 objects: additions.objects.iter().cloned().collect(),
740 })?;
741 let members = closure
742 .objects
743 .iter()
744 .chain(closure.results.iter())
745 .collect::<Vec<_>>();
746 let bytes = match form {
747 ExportForm::Tar => write_tar_export(store, archive, parent, &manifest, &members)?,
748 ExportForm::Directory => {
749 write_directory_export(store, archive, parent, &manifest, &members)?
750 }
751 };
752 Ok(TransferOutcome {
753 actions: actions.len() as u64,
754 objects: closure.objects.len() as u64,
755 bytes,
756 })
757}
758
759fn write_tar_export(
761 store: &Path,
762 archive: &Path,
763 parent: &Path,
764 manifest: &[u8],
765 members: &[&PathBuf],
766) -> Result<u64> {
767 let temporary = tempfile::Builder::new()
768 .prefix(".mbx-export-")
769 .tempfile_in(parent)?;
770 let mut builder = tar::Builder::new(temporary.reopen()?);
771 append_bytes(&mut builder, Path::new(EXPORT_MANIFEST), manifest)?;
772 for path in members {
773 append_file(&mut builder, store, path)?;
774 }
775 builder.finish()?;
776 drop(builder);
777 temporary
778 .persist(archive)
779 .map_err(|error| error.error)
780 .wrap_err_with(|| format!("failed to publish {}", archive.display()))?;
781 Ok(std::fs::metadata(archive)?.len())
782}
783
784fn write_directory_export(
790 store: &Path,
791 destination: &Path,
792 parent: &Path,
793 manifest: &[u8],
794 members: &[&PathBuf],
795) -> Result<u64> {
796 let staging = tempfile::Builder::new()
797 .prefix(".mbx-export-")
798 .tempdir_in(parent)?;
799 std::fs::write(staging.path().join(EXPORT_MANIFEST), manifest)?;
800 let mut bytes = manifest.len() as u64;
801 for path in members {
802 let relative = path.strip_prefix(store)?;
803 let target = staging.path().join(relative);
804 std::fs::create_dir_all(target.parent().expect("export member has a parent"))?;
805 bytes += std::fs::copy(path, &target)?;
806 }
807 let staged = staging.keep();
808 let retired = match std::fs::symlink_metadata(destination) {
813 Ok(_) => {
814 let holder = tempfile::Builder::new()
815 .prefix(".mbx-export-retired-")
816 .tempdir_in(parent)?;
817 let moved = holder.path().join("bundle");
818 std::fs::rename(destination, &moved)
819 .wrap_err_with(|| format!("failed to replace {}", destination.display()))?;
820 Some((holder, moved))
821 }
822 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
823 Err(error) => {
824 return Err(error)
825 .wrap_err_with(|| format!("failed to replace {}", destination.display()));
826 }
827 };
828 if let Err(error) = std::fs::rename(&staged, destination) {
829 let _ = std::fs::remove_dir_all(&staged);
830 if let Some((holder, moved)) = retired
831 && std::fs::rename(&moved, destination).is_err()
832 {
833 let kept = holder.keep();
838 log::warn!(
839 "could not restore {} after a failed export; its previous contents are at {}",
840 destination.display(),
841 kept.join("bundle").display()
842 );
843 }
844 return Err(error).wrap_err_with(|| format!("failed to publish {}", destination.display()));
845 }
846 drop(retired);
848 Ok(bytes)
849}
850
851pub fn import_archive(store: &Path, archive: &Path) -> Result<TransferOutcome> {
853 Ok(import_archive_with_attachments(store, archive)?.transfer)
854}
855
856pub fn import_archive_with_attachments(store: &Path, archive: &Path) -> Result<ImportOutcome> {
858 let staged;
862 let _staging_lock;
863 let root = if archive.is_dir() {
864 _staging_lock = None;
865 validate_directory_bundle(archive)?;
866 archive
867 } else {
868 let staging_root = store.join(IMPORT_STAGING_DIR);
874 std::fs::create_dir_all(&staging_root)?;
875 staged = tempfile::Builder::new()
876 .prefix("import-")
877 .tempdir_in(&staging_root)?;
878 _staging_lock = Some(lock_import_staging(staged.path())?);
883 unpack_archive(archive, staged.path())?;
884 staged.path()
885 };
886 let bundle_bytes = archive.is_dir().then(|| tree_bytes(archive));
889 let manifest: ExportManifest =
890 serde_json::from_slice(&std::fs::read(root.join(EXPORT_MANIFEST))?)?;
891 let actions = manifest.actions.iter().cloned().collect::<BTreeSet<_>>();
892 let task_identities = manifest
893 .tasks
894 .iter()
895 .map(|task| task.task.as_str())
896 .collect::<BTreeSet<_>>();
897 if !matches!(manifest.version, LEGACY_EXPORT_VERSION | EXPORT_VERSION)
898 || (manifest.version == LEGACY_EXPORT_VERSION
899 && (!manifest.attachments.is_empty() || !manifest.objects.is_empty()))
900 || manifest.tasks.is_empty()
901 || actions.len() != manifest.actions.len()
902 || task_identities.len() != manifest.tasks.len()
903 || manifest.tasks.iter().any(|task| !task.validate())
904 || manifest
905 .actions
906 .iter()
907 .any(|action| action.algorithm != "blake3" || action.validate().is_err())
908 || manifest.tasks.iter().any(|task| {
909 task.predictions
910 .iter()
911 .any(|prediction| !actions.contains(&prediction.action))
912 })
913 || manifest
914 .attachments
915 .keys()
916 .any(|name| !valid_attachment_name(name))
917 || manifest.attachments.values().any(|digest| {
918 digest.algorithm != "blake3"
919 || digest.validate().is_err()
920 || !manifest.objects.contains(digest)
921 })
922 || manifest.objects.iter().collect::<BTreeSet<_>>().len() != manifest.objects.len()
923 || manifest
924 .objects
925 .iter()
926 .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
927 {
928 eyre::bail!("unsupported or invalid cache export manifest");
929 }
930 let mut closure =
931 strict_closure(root, &actions).wrap_err("cache export is incomplete or corrupt")?;
932 let staged_cas = LocalCas::new(root);
933 for digest in &manifest.objects {
934 require_object(&staged_cas, &mut closure, digest)
935 .wrap_err("cache export attachment is incomplete or corrupt")?;
936 }
937 verify_pending(&mut closure.pending).wrap_err("cache export is incomplete or corrupt")?;
938
939 let results = closure
947 .results
948 .iter()
949 .map(|path| {
950 let result: RemoteActionResult = serde_json::from_slice(&std::fs::read(path)?)
951 .wrap_err_with(|| format!("action result is invalid: {}", path.display()))?;
952 Ok(result)
953 })
954 .collect::<Result<Vec<_>>>()
955 .wrap_err("cache export is incomplete or corrupt")?;
956
957 let cas = LocalCas::new(store);
958 for path in &closure.objects {
959 let relative = path.strip_prefix(root)?;
960 let source = root.join(relative);
961 let digest = addressed_digest(root, &source, false)
962 .ok_or_else(|| eyre::eyre!("invalid cache object path {}", relative.display()))?;
963 cas.adopt_verified_file(&digest, &source)?;
967 }
968 let action_cache = mbx_cache_core::LocalActionCache::new(store);
969 let mut kept_existing = 0;
970 for result in &results {
971 if action_cache.store_unless_restorable(result, |existing| {
972 extend_result_closure(
973 &cas,
974 &mut Closure::default(),
975 &mut BTreeSet::new(),
976 existing,
977 false,
978 )
979 .is_ok()
980 })? == mbx_cache_core::ActionStoreOutcome::KeptExisting
981 {
982 kept_existing += 1;
983 }
984 }
985 if kept_existing > 0 {
986 log::debug!(
987 "cache import kept {kept_existing} intact existing results that differed from the bundle"
988 );
989 }
990 for task in manifest.tasks.iter().cloned() {
991 merge_imported_manifest(store, task)?;
992 }
993 let bytes = match bundle_bytes {
994 Some(bytes) => {
995 std::fs::remove_dir_all(archive)
999 .wrap_err_with(|| format!("failed to remove {}", archive.display()))?;
1000 bytes
1001 }
1002 None => std::fs::metadata(archive)?.len(),
1003 };
1004 Ok(ImportOutcome {
1005 transfer: TransferOutcome {
1006 actions: actions.len() as u64,
1007 objects: closure.objects.len() as u64,
1008 bytes,
1009 },
1010 attachments: manifest.attachments,
1011 tasks: manifest.tasks,
1012 })
1013}
1014
1015fn validate_export_additions(additions: &ExportAdditions) -> Result<()> {
1016 if additions
1017 .attachments
1018 .keys()
1019 .any(|name| !valid_attachment_name(name))
1020 || additions
1021 .attachments
1022 .values()
1023 .any(|digest| !additions.objects.contains(digest))
1024 || additions
1025 .objects
1026 .iter()
1027 .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
1028 {
1029 eyre::bail!("invalid cache export attachment");
1030 }
1031 Ok(())
1032}
1033
1034fn valid_attachment_name(name: &str) -> bool {
1035 !name.is_empty()
1036 && name.len() <= 128
1037 && name
1038 .bytes()
1039 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
1040}
1041
1042#[derive(Default)]
1044struct Closure {
1045 objects: BTreeSet<PathBuf>,
1046 results: BTreeSet<PathBuf>,
1047 pending: Vec<PendingObject>,
1048}
1049
1050struct PendingObject {
1052 digest: CacheDigest,
1053 path: PathBuf,
1054}
1055
1056fn strict_closure(store: &Path, actions: &BTreeSet<CacheDigest>) -> Result<Closure> {
1057 let cas = LocalCas::new(store);
1058 let action_cache = mbx_cache_core::LocalActionCache::new(store);
1059 let mut closure = Closure::default();
1060 let mut directories = BTreeSet::new();
1061 for action in actions {
1062 let result = action_cache
1063 .find(action)?
1064 .ok_or_else(|| eyre::eyre!("action result is missing for {}", action.hash))?;
1065 closure.results.insert(action_cache.path_for(action)?);
1066 extend_result_closure(&cas, &mut closure, &mut directories, &result, true)?;
1067 }
1068 Ok(closure)
1069}
1070
1071fn extend_result_closure(
1074 cas: &LocalCas,
1075 closure: &mut Closure,
1076 directories: &mut BTreeSet<CacheDigest>,
1077 result: &RemoteActionResult,
1078 verify_descriptors: bool,
1079) -> Result<()> {
1080 let parsed_object = |closure: &mut Closure, digest: &CacheDigest| {
1081 if verify_descriptors {
1082 require_parsed_object(cas, closure, digest)
1083 } else {
1084 require_object(cas, closure, digest)?;
1085 cas.path_for(digest)
1086 }
1087 };
1088 require_object(cas, closure, &result.action)?;
1089 if let Some(metadata) = &result.metadata {
1090 let path = parsed_object(closure, metadata)?;
1091 let captured: CapturedOutput =
1092 serde_json::from_slice(&std::fs::read(path)?).wrap_err("action metadata is invalid")?;
1093 require_object(cas, closure, &captured.stdout)?;
1094 require_object(cas, closure, &captured.stderr)?;
1095 }
1096 if let Some(root) = &result.output_root {
1097 let mut nodes = vec![root.clone()];
1098 while let Some(digest) = nodes.pop() {
1099 if !directories.insert(digest.clone()) {
1100 continue;
1101 }
1102 let path = parsed_object(closure, &digest)?;
1103 let directory: CacheDirectory = serde_json::from_slice(&std::fs::read(path)?)
1104 .wrap_err("output directory is invalid")?;
1105 for file in directory.files {
1106 require_object(cas, closure, &file.digest)?;
1107 }
1108 for child in directory.directories {
1109 nodes.push(child.digest);
1110 }
1111 }
1112 }
1113 Ok(())
1114}
1115
1116fn verify_pending(pending: &mut [PendingObject]) -> Result<()> {
1124 if pending.is_empty() {
1125 return Ok(());
1126 }
1127 pending.sort_by(|left, right| {
1131 right
1132 .digest
1133 .size
1134 .cmp(&left.digest.size)
1135 .then_with(|| left.path.cmp(&right.path))
1136 });
1137 let workers = std::thread::available_parallelism()
1138 .map(usize::from)
1139 .unwrap_or(1)
1140 .min(pending.len());
1141 let next = AtomicUsize::new(0);
1142 let failure: Mutex<Option<(PathBuf, eyre::Report)>> = Mutex::new(None);
1145 let pending: &[PendingObject] = pending;
1146 std::thread::scope(|scope| {
1147 for _ in 0..workers {
1148 scope.spawn(|| {
1149 loop {
1150 let index = next.fetch_add(1, Ordering::Relaxed);
1151 let Some(entry) = pending.get(index) else {
1152 break;
1153 };
1154 let error = match entry.digest.matches_file(&entry.path) {
1155 Ok(true) => continue,
1156 Ok(false) => eyre::eyre!(
1157 "local CAS blob failed digest verification: {}",
1158 entry.path.display()
1159 ),
1160 Err(error) => {
1161 error.wrap_err(format!("failed to verify {}", entry.path.display()))
1162 }
1163 };
1164 let mut failure = failure.lock().unwrap_or_else(|error| error.into_inner());
1165 if failure.as_ref().is_none_or(|(path, _)| entry.path < *path) {
1166 *failure = Some((entry.path.clone(), error));
1167 }
1168 }
1169 });
1170 }
1171 });
1172 match failure
1173 .into_inner()
1174 .unwrap_or_else(|error| error.into_inner())
1175 {
1176 Some((_, error)) => Err(error),
1177 None => Ok(()),
1178 }
1179}
1180
1181#[derive(Deserialize)]
1182struct CapturedOutput {
1183 stdout: CacheDigest,
1184 stderr: CacheDigest,
1185}
1186
1187fn merge_imported_manifest(destination: &Path, mut imported: TaskActionManifest) -> Result<()> {
1188 let identity = imported.task.clone();
1189 let destination_path = task_manifest_path(destination, &identity);
1190 let lock_path = task_manifest_lock_path(destination, &identity);
1191 std::fs::create_dir_all(lock_path.parent().expect("task manifest lock has a parent"))?;
1192 let mut lock = fslock::LockFile::open(&lock_path)?;
1193 lock.lock()?;
1194 if let Ok(bytes) = std::fs::read(&destination_path)
1195 && let Ok(existing) = serde_json::from_slice::<TaskActionManifest>(&bytes)
1196 && existing.task == identity
1197 && existing.validate()
1198 {
1199 let existing_predictions: BTreeMap<_, _> = existing
1200 .predictions
1201 .iter()
1202 .cloned()
1203 .map(|prediction| (prediction.invocation.clone(), prediction))
1204 .collect();
1205 let updates = imported
1208 .predictions
1209 .into_iter()
1210 .map(|prediction| {
1211 existing_predictions
1212 .get(&prediction.invocation)
1213 .cloned()
1214 .unwrap_or(prediction)
1215 })
1216 .collect();
1217 imported.predictions =
1218 merge_task_action_predictions(existing.predictions, updates, &BTreeSet::new())?;
1219 }
1220 write_atomic(&destination_path, &serde_json::to_vec(&imported)?)
1221}
1222
1223fn require_object(cas: &LocalCas, closure: &mut Closure, digest: &CacheDigest) -> Result<()> {
1230 let expected = cas.path_for(digest)?;
1231 if !closure.objects.insert(expected.clone()) {
1232 return Ok(());
1233 }
1234 let metadata = match std::fs::metadata(&expected) {
1235 Ok(metadata) => metadata,
1236 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1237 eyre::bail!("cache object is missing for {}", digest.hash)
1238 }
1239 Err(error) => {
1240 return Err(error).wrap_err_with(|| format!("failed to read {}", expected.display()));
1241 }
1242 };
1243 if metadata.len() != digest.size {
1244 eyre::bail!(
1245 "local CAS blob failed digest verification: {}",
1246 expected.display()
1247 );
1248 }
1249 closure.pending.push(PendingObject {
1250 digest: digest.clone(),
1251 path: expected,
1252 });
1253 Ok(())
1254}
1255
1256fn require_parsed_object(
1263 cas: &LocalCas,
1264 closure: &mut Closure,
1265 digest: &CacheDigest,
1266) -> Result<PathBuf> {
1267 let path = cas
1268 .find(digest)?
1269 .ok_or_else(|| eyre::eyre!("cache object is missing for {}", digest.hash))?;
1270 closure.objects.insert(path.clone());
1271 Ok(path)
1272}
1273
1274#[derive(Default)]
1275struct ManifestPrune {
1276 removed_manifests: u64,
1277 removed_bytes: u64,
1278}
1279
1280fn prune_task_manifests(store: &Path, live: &BTreeSet<String>, dry_run: bool) -> ManifestPrune {
1295 let mut prune = ManifestPrune::default();
1296 let root = store.join(TASK_MANIFESTS_DIR);
1297 let mut removed = BTreeSet::new();
1298 let manifests = read_dir_or_empty(&root).unwrap_or_else(|error| {
1299 log::warn!("could not list task manifests: {error}");
1300 Vec::new()
1301 });
1302 for entry in manifests {
1303 let name = entry.file_name().to_string_lossy().into_owned();
1304 let Some(identity) = name.strip_suffix(".json") else {
1305 continue;
1306 };
1307 if !is_task_identity(identity) || live.contains(identity) {
1308 continue;
1309 }
1310 let Ok(metadata) = entry.metadata() else {
1311 continue;
1312 };
1313 if !metadata.is_file() || !task_manifest_is_idle(&metadata) {
1314 continue;
1315 }
1316 let lock_path = task_manifest_lock_path(store, identity);
1317 if dry_run {
1318 if lock_path.exists() && claim_task_manifest_lock(&lock_path).is_none() {
1322 continue;
1323 }
1324 prune.removed_manifests += 1;
1325 prune.removed_bytes += metadata.len();
1326 continue;
1327 }
1328 let Some(guard) = claim_task_manifest_lock(&lock_path) else {
1331 continue;
1332 };
1333 let Ok(metadata) = std::fs::metadata(entry.path()) else {
1336 continue;
1337 };
1338 if !metadata.is_file() || !task_manifest_is_idle(&metadata) {
1339 continue;
1340 }
1341 match remove(&entry.path()) {
1342 Ok(Removal::Removed) => {
1343 removed.insert(identity.to_string());
1344 prune.removed_manifests += 1;
1345 prune.removed_bytes += metadata.len();
1346 }
1347 Ok(_) => {}
1348 Err(error) => log::warn!("could not remove task manifest {name}: {error}"),
1349 }
1350 drop(guard);
1351 if removed.contains(identity) {
1352 let _ = std::fs::remove_file(&lock_path);
1353 }
1354 }
1355
1356 if !dry_run {
1360 let locks = read_dir_or_empty(&root.join("locks")).unwrap_or_else(|error| {
1361 log::warn!("could not list task manifest locks: {error}");
1362 Vec::new()
1363 });
1364 for entry in locks {
1365 let name = entry.file_name().to_string_lossy().into_owned();
1366 let Some(identity) = name.strip_suffix(".lock") else {
1367 continue;
1368 };
1369 if !is_task_identity(identity)
1370 || live.contains(identity)
1371 || task_manifest_path(store, identity).exists()
1372 {
1373 continue;
1374 }
1375 let idle = entry
1376 .metadata()
1377 .is_ok_and(|metadata| metadata.is_file() && task_manifest_is_idle(&metadata));
1378 if idle && let Some(guard) = claim_task_manifest_lock(&entry.path()) {
1379 drop(guard);
1380 let _ = std::fs::remove_file(entry.path());
1381 }
1382 }
1383 }
1384 prune
1385}
1386
1387fn task_manifest_is_idle(metadata: &std::fs::Metadata) -> bool {
1388 metadata
1389 .modified()
1390 .ok()
1391 .and_then(|at| SystemTime::now().duration_since(at).ok())
1392 .is_some_and(|age| age > TASK_MANIFEST_GRACE)
1393}
1394
1395fn claim_task_manifest_lock(path: &Path) -> Option<fslock::LockFile> {
1397 let mut lock = fslock::LockFile::open(path).ok()?;
1398 lock.try_lock().ok()?.then_some(lock)
1399}
1400
1401fn task_manifest_path(store: &Path, identity: &str) -> PathBuf {
1402 store
1403 .join(TASK_MANIFESTS_DIR)
1404 .join(format!("{identity}.json"))
1405}
1406
1407fn task_manifest_lock_path(store: &Path, identity: &str) -> PathBuf {
1408 store
1409 .join(TASK_MANIFESTS_DIR)
1410 .join("locks")
1411 .join(format!("{identity}.lock"))
1412}
1413
1414fn append_file(builder: &mut tar::Builder<std::fs::File>, root: &Path, path: &Path) -> Result<()> {
1415 let name = path.strip_prefix(root)?;
1416 builder.append_path_with_name(path, name)?;
1417 Ok(())
1418}
1419
1420fn append_bytes(
1421 builder: &mut tar::Builder<std::fs::File>,
1422 name: &Path,
1423 bytes: &[u8],
1424) -> Result<()> {
1425 let mut header = tar::Header::new_gnu();
1426 header.set_size(bytes.len() as u64);
1427 header.set_mode(0o644);
1428 header.set_cksum();
1429 builder.append_data(&mut header, name, bytes)?;
1430 Ok(())
1431}
1432
1433fn unpack_archive(archive: &Path, staging: &Path) -> Result<()> {
1435 let file = std::fs::File::open(archive)
1436 .wrap_err_with(|| format!("failed to open {}", archive.display()))?;
1437 let mut bundle = tar::Archive::new(file);
1438 let mut seen = BTreeSet::new();
1439 for entry in bundle.entries()? {
1440 let mut entry = entry?;
1441 let entry_type = entry.header().entry_type();
1442 if !entry_type.is_file() && !entry_type.is_gnu_sparse() {
1443 eyre::bail!("cache export contains a non-file entry");
1444 }
1445 let path = entry.path()?.into_owned();
1446 validate_archive_path(&path)?;
1447 if !seen.insert(path.clone()) {
1448 eyre::bail!("cache export contains duplicate entry {}", path.display());
1449 }
1450 let destination = staging.join(&path);
1451 std::fs::create_dir_all(destination.parent().expect("entry has a parent"))?;
1452 entry.unpack(&destination)?;
1456 }
1457 Ok(())
1458}
1459
1460fn validate_directory_bundle(root: &Path) -> Result<()> {
1468 let mut pending = vec![root.to_path_buf()];
1469 while let Some(directory) = pending.pop() {
1470 for entry in std::fs::read_dir(&directory)
1471 .wrap_err_with(|| format!("failed to read {}", directory.display()))?
1472 {
1473 let entry = entry?;
1474 let metadata = entry.metadata()?;
1475 let path = entry.path();
1476 if metadata.is_dir() {
1477 pending.push(path);
1478 continue;
1479 }
1480 if !metadata.is_file() {
1481 eyre::bail!("cache export contains a non-file entry");
1482 }
1483 validate_archive_path(path.strip_prefix(root)?)?;
1484 reject_linked_file(&metadata, &path)?;
1485 }
1486 }
1487 Ok(())
1488}
1489
1490#[cfg(unix)]
1497fn reject_linked_file(metadata: &std::fs::Metadata, path: &Path) -> Result<()> {
1498 use std::os::unix::fs::MetadataExt as _;
1499
1500 if metadata.nlink() > 1 {
1501 eyre::bail!("cache export contains a hard link at {}", path.display());
1502 }
1503 Ok(())
1504}
1505
1506#[cfg(not(unix))]
1512fn reject_linked_file(_metadata: &std::fs::Metadata, _path: &Path) -> Result<()> {
1513 Ok(())
1514}
1515
1516fn validate_archive_path(path: &Path) -> Result<()> {
1517 if path.as_os_str().is_empty()
1518 || path.is_absolute()
1519 || path
1520 .components()
1521 .any(|component| !matches!(component, Component::Normal(_)))
1522 {
1523 eyre::bail!("cache export contains unsafe path {}", path.display());
1524 }
1525 let parts = path
1530 .components()
1531 .map(|component| component.as_os_str().to_string_lossy().into_owned())
1532 .collect::<Vec<_>>();
1533 let expected = match parts.as_slice() {
1534 [name] => name == EXPORT_MANIFEST,
1535 [root, version, _rest @ ..] => {
1536 parts.len() > 2 && (root == "cas" || root == "action-results") && version == "v1"
1537 }
1538 [] => false,
1539 };
1540 if !expected {
1541 eyre::bail!("cache export contains unexpected path {}", path.display());
1542 }
1543 Ok(())
1544}
1545
1546#[derive(Debug, Clone, Serialize, Deserialize)]
1553#[serde(deny_unknown_fields)]
1554struct CheckoutRecord {
1555 version: u8,
1556 workspace_root: PathBuf,
1557 target_dir: PathBuf,
1558 updated_secs: u64,
1559}
1560
1561pub fn stats(store: &Path) -> Result<StoreStats> {
1563 let objects = walk_files(&store.join(CAS_DIR))?;
1564 let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
1565 let checkouts = scan_checkouts(store)?;
1566 Ok(StoreStats {
1567 objects: objects.len() as u64,
1568 object_bytes: objects.iter().map(|entry| entry.size).sum(),
1569 action_results: results.len() as u64,
1570 action_result_bytes: results.iter().map(|entry| entry.size).sum(),
1571 live_checkouts: checkouts.live_records,
1572 stale_checkouts: checkouts.stale_records.len() as u64,
1573 })
1574}
1575
1576fn checkout_target_bytes(sizes: &mut BTreeMap<PathBuf, u64>, record: &CheckoutRecord) -> u64 {
1583 if record.target_dir == record.workspace_root {
1584 return 0;
1585 }
1586 cached_tree_bytes(sizes, &record.target_dir)
1587}
1588
1589pub fn projects(store: &Path) -> Result<Vec<ProjectUsage>> {
1595 project_usage(store, true)
1596}
1597
1598pub fn live_project_cache_bytes(store: &Path) -> Result<Vec<u64>> {
1604 Ok(project_usage(store, false)?
1605 .into_iter()
1606 .filter(|project| project.live)
1607 .map(|project| project.action_bytes)
1608 .collect())
1609}
1610
1611fn project_usage(store: &Path, size_targets: bool) -> Result<Vec<ProjectUsage>> {
1612 let mut projects: BTreeMap<PathBuf, (BTreeSet<String>, bool, u64)> = BTreeMap::new();
1613 let mut target_sizes = BTreeMap::new();
1614 let root = store.join(CHECKOUTS_DIR);
1615 for identity_entry in read_dir_or_empty(&root)? {
1616 let identity = identity_entry.file_name().to_string_lossy().into_owned();
1617 if !is_task_identity(&identity) || !identity_entry.file_type()?.is_dir() {
1618 continue;
1619 }
1620 for entry in walk_files(&identity_entry.path())? {
1621 let Some(record) = read_checkout_record(&entry.path) else {
1622 continue;
1623 };
1624 let project = projects.entry(record.workspace_root.clone()).or_default();
1625 if claim_is_live(store, &record) {
1626 project.0.insert(identity.clone());
1627 project.1 = true;
1628 }
1629 if size_targets {
1630 project.2 = project
1631 .2
1632 .max(checkout_target_bytes(&mut target_sizes, &record));
1633 }
1634 }
1635 }
1636 let live = projects
1637 .values()
1638 .flat_map(|(identities, _, _)| identities.iter().cloned())
1639 .collect::<BTreeSet<_>>();
1640 let mut reachability = Reachability::new(store);
1641 resolve_identities(&reachability, &live);
1642 let mut usages = Vec::new();
1643 for (workspace_root, (identities, live, target_bytes)) in projects {
1644 let action_bytes = reachability.bytes(&identities);
1645 usages.push(ProjectUsage {
1646 workspace_root,
1647 identities: identities.len() as u64,
1648 action_bytes,
1649 target_bytes,
1650 live,
1651 });
1652 }
1653 usages.sort_by(|left, right| {
1654 right
1655 .action_bytes
1656 .saturating_add(right.target_bytes)
1657 .cmp(&left.action_bytes.saturating_add(left.target_bytes))
1658 .then_with(|| left.workspace_root.cmp(&right.workspace_root))
1659 });
1660 Ok(usages)
1661}
1662
1663fn resolve_identities(reachability: &Reachability, identities: &BTreeSet<String>) {
1671 const MAX_WORKERS: usize = 8;
1672 let identities = identities.iter().collect::<Vec<_>>();
1673 let workers = std::thread::available_parallelism()
1674 .map(usize::from)
1675 .unwrap_or(1)
1676 .min(MAX_WORKERS)
1677 .min(identities.len());
1678 let next = AtomicUsize::new(0);
1679 std::thread::scope(|scope| {
1680 for _ in 0..workers {
1681 scope.spawn(|| {
1682 while let Some(identity) = identities.get(next.fetch_add(1, Ordering::Relaxed)) {
1683 let actions = task_manifest_actions(&reachability.store, identity)
1687 .unwrap_or_else(|error| {
1688 log::debug!("could not read the manifest for {identity}: {error}");
1689 Vec::new()
1690 });
1691 reachability.record(identity, &actions);
1692 }
1693 });
1694 }
1695 });
1696}
1697
1698struct Reachability {
1711 store: PathBuf,
1712 cas: LocalCas,
1713 action_cache: mbx_cache_core::LocalActionCache,
1714 paths: Mutex<PathIndex>,
1716 identities: Mutex<HashMap<String, ActionPaths>>,
1717 actions: Mutex<HashMap<CacheDigest, Arc<[usize]>>>,
1718 directories: Mutex<HashMap<CacheDigest, Option<DirectoryPaths>>>,
1720 counted: Vec<usize>,
1722 workspaces: usize,
1723}
1724
1725#[derive(Default)]
1726struct PathIndex {
1727 indices: HashMap<PathBuf, usize>,
1728 sizes: Vec<u64>,
1729}
1730
1731type ActionPaths = Arc<[Arc<[usize]>]>;
1733
1734type DirectoryPaths = (Arc<[CacheDigest]>, Arc<[usize]>);
1735
1736fn locked<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
1739 mutex.lock().unwrap_or_else(|error| error.into_inner())
1740}
1741
1742impl Reachability {
1743 fn new(store: &Path) -> Self {
1744 Self {
1745 store: store.to_path_buf(),
1746 cas: LocalCas::new(store),
1747 action_cache: mbx_cache_core::LocalActionCache::new(store),
1748 paths: Mutex::default(),
1749 identities: Mutex::default(),
1750 actions: Mutex::default(),
1751 directories: Mutex::default(),
1752 counted: Vec::new(),
1753 workspaces: 0,
1754 }
1755 }
1756
1757 fn bytes(&mut self, identities: &BTreeSet<String>) -> u64 {
1761 let reachable = {
1762 let recorded = locked(&self.identities);
1763 identities
1764 .iter()
1765 .filter_map(|identity| recorded.get(identity).cloned())
1766 .collect::<Vec<_>>()
1767 };
1768 let sizes = std::mem::take(&mut locked(&self.paths).sizes);
1769 self.workspaces += 1;
1770 self.counted.resize(sizes.len(), 0);
1771 let mut bytes = 0_u64;
1772 for index in reachable
1773 .iter()
1774 .flat_map(|actions| actions.iter())
1775 .flat_map(|paths| paths.iter())
1776 {
1777 if self.counted[*index] != self.workspaces {
1778 self.counted[*index] = self.workspaces;
1779 bytes = bytes.saturating_add(sizes[*index]);
1780 }
1781 }
1782 locked(&self.paths).sizes = sizes;
1783 bytes
1784 }
1785
1786 fn record(&self, identity: &str, actions: &[CacheDigest]) {
1791 let actions = actions
1792 .iter()
1793 .map(|action| self.action(action))
1794 .collect::<Arc<[_]>>();
1795 locked(&self.identities).insert(identity.to_string(), actions);
1796 }
1797
1798 fn action(&self, action: &CacheDigest) -> Arc<[usize]> {
1799 if let Some(paths) = locked(&self.actions).get(action) {
1800 return paths.clone();
1801 }
1802 let mut paths = Vec::new();
1803 if let Ok(path) = self.action_cache.path_for(action) {
1804 paths.push(self.index(path));
1805 }
1806 if let Some(result) = read_action_result(&self.store, action) {
1807 self.push_digest(&mut paths, &result.action);
1808 if let Some(metadata) = &result.metadata {
1809 self.push_digest(&mut paths, metadata);
1810 if let Some(rustc) = read_rustc_metadata(&self.cas, metadata) {
1811 self.push_digest(&mut paths, &rustc.stdout);
1812 self.push_digest(&mut paths, &rustc.stderr);
1813 }
1814 }
1815 if let Some(output_root) = &result.output_root {
1816 self.push_digest(&mut paths, output_root);
1817 let mut pending = vec![output_root.clone()];
1818 let mut visited = HashSet::new();
1819 while let Some(digest) = pending.pop() {
1820 if !visited.insert(digest.clone()) {
1821 continue;
1822 }
1823 let Some((children, named)) = self.directory(&digest) else {
1824 continue;
1825 };
1826 paths.extend(named.iter().copied());
1827 pending.extend(children.iter().cloned());
1828 }
1829 }
1830 }
1831 paths.sort_unstable();
1832 paths.dedup();
1833 let paths = Arc::<[usize]>::from(paths);
1834 locked(&self.actions).insert(action.clone(), paths.clone());
1835 paths
1836 }
1837
1838 fn directory(&self, digest: &CacheDigest) -> Option<DirectoryPaths> {
1840 if let Some(entry) = locked(&self.directories).get(digest) {
1841 return entry.clone();
1842 }
1843 let entry = read_directory(&self.cas, digest).map(|directory| {
1844 let mut paths = Vec::new();
1845 for file in &directory.files {
1846 self.push_digest(&mut paths, &file.digest);
1847 }
1848 for child in &directory.directories {
1849 self.push_digest(&mut paths, &child.digest);
1850 }
1851 let children = directory
1852 .directories
1853 .into_iter()
1854 .map(|child| child.digest)
1855 .collect::<Arc<[_]>>();
1856 (children, Arc::<[usize]>::from(paths))
1857 });
1858 locked(&self.directories).insert(digest.clone(), entry.clone());
1859 entry
1860 }
1861
1862 fn push_digest(&self, paths: &mut Vec<usize>, digest: &CacheDigest) {
1863 if let Ok(path) = self.cas.path_for(digest) {
1864 paths.push(self.index(path));
1865 }
1866 }
1867
1868 fn index(&self, path: PathBuf) -> usize {
1869 if let Some(index) = locked(&self.paths).indices.get(&path) {
1870 return *index;
1871 }
1872 let size = std::fs::metadata(&path).map_or(0, |metadata| metadata.len());
1875 let mut paths = locked(&self.paths);
1876 if let Some(index) = paths.indices.get(&path) {
1877 return *index;
1878 }
1879 let index = paths.sizes.len();
1880 paths.sizes.push(size);
1881 paths.indices.insert(path, index);
1882 index
1883 }
1884}
1885
1886pub fn largest(store: &Path, limit: usize) -> Result<Vec<LargestEntry>> {
1888 let mut entries = Vec::new();
1889 for (kind, root, action_result) in [
1890 ("object", store.join(CAS_DIR), false),
1891 ("action result", store.join(ACTION_RESULTS_DIR), true),
1892 ] {
1893 entries.extend(
1894 walk_files(&root)?
1895 .into_iter()
1896 .filter(|entry| addressed_digest(store, &entry.path, action_result).is_some())
1897 .map(|entry| LargestEntry {
1898 kind,
1899 path: entry.path,
1900 bytes: entry.size,
1901 }),
1902 );
1903 }
1904 entries.sort_by(|left, right| {
1905 right
1906 .bytes
1907 .cmp(&left.bytes)
1908 .then_with(|| left.path.cmp(&right.path))
1909 });
1910 entries.truncate(limit);
1911 Ok(entries)
1912}
1913
1914pub fn verify(store: &Path) -> Result<VerifyOutcome> {
1916 let cas = LocalCas::new(store);
1917 let action_cache = mbx_cache_core::LocalActionCache::new(store);
1918 let virtually_removed = HashSet::new();
1921 let mut outcome = VerifyOutcome::default();
1922 let mut trees = HashMap::new();
1923 for entry in walk_files(&store.join(CAS_DIR))? {
1924 let Some(digest) = addressed_digest(store, &entry.path, false) else {
1925 continue;
1926 };
1927 outcome.checked_objects += 1;
1928 if !matches!(cas.find(&digest), Ok(Some(_))) {
1929 outcome.problems.push(entry.path);
1930 }
1931 }
1932 for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
1933 let Some(digest) = addressed_digest(store, &entry.path, true) else {
1934 continue;
1935 };
1936 outcome.checked_action_results += 1;
1937 if !matches!(action_cache.find(&digest), Ok(Some(_)))
1938 || action_result_is_dangling(&cas, &entry.path, &virtually_removed, true, &mut trees)?
1939 {
1940 outcome.problems.push(entry.path);
1941 }
1942 }
1943 outcome.problems.sort();
1944 Ok(outcome)
1945}
1946
1947pub fn remove_project(store: &Path, workspace_root: &Path) -> Result<RemoveProjectOutcome> {
1949 let mut outcome = RemoveProjectOutcome::default();
1950 for entry in walk_files(&store.join(CHECKOUTS_DIR))? {
1951 if read_checkout_record(&entry.path)
1952 .is_some_and(|record| record.workspace_root == workspace_root)
1953 {
1954 std::fs::remove_file(&entry.path)?;
1955 outcome.removed_checkout_records += 1;
1956 if let Some(parent) = entry.path.parent() {
1957 let _ = std::fs::remove_dir(parent);
1958 }
1959 }
1960 }
1961 match std::fs::remove_file(latest_receipt_path(store, workspace_root)) {
1962 Ok(()) => {}
1963 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1964 Err(error) => return Err(error.into()),
1965 }
1966 Ok(outcome)
1967}
1968
1969pub fn record_checkout(
1981 store: &Path,
1982 identity: &str,
1983 workspace_root: &Path,
1984 target_dir: &Path,
1985) -> Result<()> {
1986 let record = CheckoutRecord {
1987 version: CHECKOUT_RECORD_VERSION,
1988 workspace_root: workspace_root.to_path_buf(),
1989 target_dir: target_dir.to_path_buf(),
1990 updated_secs: SystemTime::now()
1991 .duration_since(UNIX_EPOCH)
1992 .map(|since| since.as_secs())
1993 .unwrap_or_default(),
1994 };
1995 let mut contents = serde_json::to_vec(&record)?;
1996 contents.push(b'\n');
1997 write_advisory(
2000 &checkout_record_path(store, identity, workspace_root),
2001 &contents,
2002 )
2003}
2004
2005fn checkout_record_path(store: &Path, identity: &str, workspace_root: &Path) -> PathBuf {
2006 let key = CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash;
2010 store
2011 .join(CHECKOUTS_DIR)
2012 .join(identity)
2013 .join(format!("{key}.json"))
2014}
2015
2016pub fn gc(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
2026 gc_with_mode(store, max_bytes, false, HashSet::new())
2027}
2028
2029pub fn gc_dry_run(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
2031 gc_with_mode(store, max_bytes, true, HashSet::new())
2032}
2033
2034#[cfg(test)]
2035type DanglingReadHook = Box<dyn FnMut(&Path)>;
2036
2037#[cfg(test)]
2038thread_local! {
2039 static ACTION_RECORD_CLAIMS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
2040 static AFTER_DANGLING_READ: std::cell::RefCell<Option<DanglingReadHook>> =
2042 const { std::cell::RefCell::new(None) };
2043}
2044
2045fn gc_with_mode(
2048 store: &Path,
2049 max_bytes: u64,
2050 dry_run: bool,
2051 already_removed: HashSet<PathBuf>,
2052) -> Result<GcOutcome> {
2053 let mut objects = walk_files(&store.join(CAS_DIR))?;
2054 let mut results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
2055 objects.retain(|entry| !already_removed.contains(&entry.path));
2056 results.retain(|entry| !already_removed.contains(&entry.path));
2057 let mut live_bytes = objects
2058 .iter()
2059 .chain(results.iter())
2060 .map(|entry| entry.size)
2061 .sum::<u64>();
2062
2063 let mut outcome = GcOutcome::default();
2064
2065 let checkouts = scan_checkouts(store)?;
2068 for path in &checkouts.stale_records {
2069 if dry_run || matches!(remove(path)?, Removal::Removed) {
2070 outcome.removed_checkout_records += 1;
2071 }
2072 if !dry_run && let Some(parent) = path.parent() {
2075 let _ = std::fs::remove_dir(parent);
2076 }
2077 }
2078
2079 let manifests = prune_task_manifests(store, &checkouts.live_identities, dry_run);
2080 outcome.removed_task_manifests += manifests.removed_manifests;
2081 outcome.removed_bytes += manifests.removed_bytes;
2082
2083 let mut virtually_removed = already_removed;
2084 if live_bytes > max_bytes {
2085 let mut rooted = rooted_objects(store, &checkouts.live_identities)?;
2089 rooted.extend(rooted_action_objects(
2096 store,
2097 grouped_receipt_actions(store)?,
2098 ));
2099 objects.sort_by_cached_key(|entry| (rooted.contains(&entry.path), entry.used));
2103 let evicted = evict_objects(&objects, &rooted, live_bytes, max_bytes, |path| {
2104 if dry_run {
2105 virtually_removed.insert(path.to_path_buf());
2106 Ok(Removal::Removed)
2107 } else {
2108 remove(path)
2109 }
2110 })?;
2111 live_bytes = evicted.remaining_bytes;
2112 outcome.removed_objects += evicted.removed_objects;
2113 outcome.removed_bytes += evicted.removed_bytes;
2114 }
2115
2116 let walk_trees = outcome.removed_objects > 0;
2129 let cas = LocalCas::new(store);
2130 let mut trees = HashMap::new();
2131 for entry in &results {
2132 match action_result_is_dangling(
2133 &cas,
2134 &entry.path,
2135 &virtually_removed,
2136 walk_trees,
2137 &mut trees,
2138 ) {
2139 Ok(false) => continue,
2140 Ok(true) => {}
2141 Err(error)
2143 if error
2144 .chain()
2145 .filter_map(|cause| cause.downcast_ref::<std::io::Error>())
2146 .any(held_by_another_process) =>
2147 {
2148 log::debug!(
2149 "could not inspect {} for eviction: {error}",
2150 entry.path.display()
2151 );
2152 continue;
2153 }
2154 Err(error) => return Err(error),
2155 }
2156 let removal = if dry_run {
2157 Removal::Removed
2158 } else {
2159 #[cfg(test)]
2160 AFTER_DANGLING_READ.with(|hook| {
2161 if let Some(hook) = hook.borrow_mut().as_mut() {
2162 hook(&entry.path);
2163 }
2164 });
2165 #[cfg(test)]
2166 ACTION_RECORD_CLAIMS.with(|count| count.set(count.get() + 1));
2167 match ActionSlot::try_claim(&entry.path) {
2168 Err(error) if held_by_another_process(&error) => {
2169 log::debug!(
2170 "could not claim {} for eviction: {error}",
2171 entry.path.display()
2172 );
2173 Removal::Blocked
2174 }
2175 Err(error) => return Err(error.into()),
2176 Ok(None) => Removal::Blocked,
2177 Ok(Some(slot)) => match slot.read()? {
2178 None => Removal::Missing,
2179 Some(bytes) => {
2180 if !action_result_bytes_are_dangling(
2183 &cas,
2184 &bytes,
2185 &virtually_removed,
2186 walk_trees,
2187 &mut HashMap::new(),
2188 )? {
2189 continue;
2190 }
2191 match slot.remove() {
2192 Ok(true) => Removal::Removed,
2193 Ok(false) => Removal::Missing,
2194 Err(error) if held_by_another_process(&error) => {
2197 log::debug!("could not evict {}: {error}", entry.path.display());
2198 Removal::Blocked
2199 }
2200 Err(error) => {
2201 return Err(error).wrap_err_with(|| {
2202 format!("failed to evict {}", entry.path.display())
2203 });
2204 }
2205 }
2206 }
2207 },
2208 }
2209 };
2210 match removal {
2211 Removal::Removed => {
2212 live_bytes = live_bytes.saturating_sub(entry.size);
2213 outcome.removed_action_results += 1;
2214 outcome.removed_bytes += entry.size;
2215 }
2216 Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
2217 Removal::Blocked => {}
2218 }
2219 }
2220
2221 let sessions = prune_sessions(store, dry_run)?;
2222 outcome.removed_session_streams += sessions.removed_streams;
2223 outcome.removed_bytes += sessions.removed_bytes;
2224 prune_import_staging(store, dry_run);
2225
2226 outcome.remaining_bytes = live_bytes;
2227 Ok(outcome)
2228}
2229
2230pub fn gc_stale_compilers(store: &Path, max_age: Duration, dry_run: bool) -> Result<GcOutcome> {
2244 gc_compilers(
2245 store,
2246 &CompilerSelection {
2247 max_age: Some(max_age),
2248 release: None,
2249 },
2250 dry_run,
2251 )
2252}
2253
2254#[derive(Debug, Clone, Default)]
2256pub struct CompilerSelection {
2257 pub max_age: Option<Duration>,
2259 pub release: Option<String>,
2267}
2268
2269pub fn gc_compilers(
2274 store: &Path,
2275 selection: &CompilerSelection,
2276 dry_run: bool,
2277) -> Result<GcOutcome> {
2278 stale_compilers_pass(store, selection, dry_run).map(|(outcome, _)| outcome)
2279}
2280
2281pub fn gc_dry_run_after_stale(
2284 store: &Path,
2285 max_bytes: u64,
2286 max_age: Duration,
2287) -> Result<GcOutcome> {
2288 gc_dry_run_after_compilers(
2289 store,
2290 max_bytes,
2291 &CompilerSelection {
2292 max_age: Some(max_age),
2293 release: None,
2294 },
2295 )
2296}
2297
2298pub fn gc_dry_run_after_compilers(
2301 store: &Path,
2302 max_bytes: u64,
2303 selection: &CompilerSelection,
2304) -> Result<GcOutcome> {
2305 let (mut stale, planned) = stale_compilers_pass(store, selection, true)?;
2306 let budget = gc_with_mode(store, max_bytes, true, planned)?;
2307 stale.removed_objects += budget.removed_objects;
2308 stale.removed_action_results += budget.removed_action_results;
2309 stale.removed_checkout_records += budget.removed_checkout_records;
2310 stale.removed_task_manifests += budget.removed_task_manifests;
2311 stale.removed_session_streams += budget.removed_session_streams;
2312 stale.removed_bytes += budget.removed_bytes;
2313 stale.remaining_bytes = budget.remaining_bytes;
2314 Ok(stale)
2315}
2316
2317fn stale_compilers_pass(
2319 store: &Path,
2320 selection: &CompilerSelection,
2321 dry_run: bool,
2322) -> Result<(GcOutcome, HashSet<PathBuf>)> {
2323 struct Attributed {
2324 entry: Entry,
2325 action: CacheDigest,
2326 }
2327
2328 let cas = LocalCas::new(store);
2329 let mut by_version: BTreeMap<String, Vec<Attributed>> = BTreeMap::new();
2330 let mut unattributed = Vec::new();
2331 for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
2332 let result = read_result_untouched(&entry.path)
2333 .ok()
2334 .and_then(|bytes| serde_json::from_slice::<RemoteActionResult>(&bytes).ok());
2335 let Some(result) = result else {
2336 continue;
2337 };
2338 match rustc_version_of(&cas, &result.action) {
2339 Some(version) => by_version.entry(version).or_default().push(Attributed {
2340 entry,
2341 action: result.action,
2342 }),
2343 None => unattributed.push(result.action),
2344 }
2345 }
2346
2347 let last_used = |results: &[Attributed]| results.iter().map(|r| r.entry.used).max();
2348 let newest = by_version
2349 .iter()
2350 .max_by_key(|(_, results)| last_used(results))
2351 .map(|(version, _)| version.clone());
2352 let cutoff = selection.max_age.map(|max_age| {
2353 SystemTime::now()
2354 .checked_sub(max_age)
2355 .unwrap_or(SystemTime::UNIX_EPOCH)
2356 });
2357 let mut outcome = GcOutcome::default();
2358 let mut stale = Vec::new();
2359 let mut kept_actions = unattributed;
2360 for (version, results) in by_version {
2361 let idle = cutoff
2362 .zip(last_used(&results))
2363 .is_some_and(|(cutoff, used)| used < cutoff)
2364 && Some(&version) != newest.as_ref();
2365 let named = selection
2366 .release
2367 .as_deref()
2368 .is_some_and(|release| names_compiler(release, &version));
2369 if idle || named {
2370 stale.extend(results);
2371 } else {
2372 kept_actions.extend(results.into_iter().map(|r| r.action));
2373 }
2374 }
2375 let pending = grouped_receipt_actions(store)?;
2378 let (pinned, stale): (Vec<_>, Vec<_>) =
2379 stale.into_iter().partition(|r| pending.contains(&r.action));
2380 kept_actions.extend(pinned.into_iter().map(|r| r.action));
2381 let mut planned = HashSet::new();
2382 if stale.is_empty() {
2383 return Ok((outcome, planned));
2384 }
2385
2386 let doomed = rooted_action_objects(store, stale.iter().map(|r| r.action.clone()));
2387 let kept = rooted_action_objects(store, kept_actions);
2388 for result in &stale {
2389 if dry_run || matches!(remove(&result.entry.path)?, Removal::Removed) {
2390 outcome.removed_action_results += 1;
2391 outcome.removed_bytes += result.entry.size;
2392 planned.insert(result.entry.path.clone());
2393 }
2394 }
2395 for path in doomed.difference(&kept) {
2396 let Ok(metadata) = std::fs::metadata(path) else {
2397 continue;
2398 };
2399 if dry_run || matches!(remove(path)?, Removal::Removed) {
2400 outcome.removed_objects += 1;
2401 outcome.removed_bytes += metadata.len();
2402 planned.insert(path.clone());
2403 }
2404 }
2405 Ok((outcome, planned))
2406}
2407
2408fn read_result_untouched(path: &Path) -> std::io::Result<Vec<u8>> {
2418 #[cfg(target_os = "linux")]
2419 {
2420 use std::io::Read as _;
2421 use std::os::unix::fs::OpenOptionsExt as _;
2422 if let Ok(mut file) = std::fs::OpenOptions::new()
2423 .read(true)
2424 .custom_flags(libc::O_NOATIME)
2425 .open(path)
2426 {
2427 let mut bytes = Vec::new();
2428 file.read_to_end(&mut bytes)?;
2429 return Ok(bytes);
2430 }
2431 }
2432 let before = std::fs::metadata(path)?;
2433 let bytes = std::fs::read(path)?;
2434 let _ = filetime::set_file_atime(path, filetime::FileTime::from_last_access_time(&before));
2435 Ok(bytes)
2436}
2437
2438fn names_compiler(wanted: &str, version: &str) -> bool {
2444 let wanted = wanted.trim();
2445 let wanted = wanted.strip_prefix("rustc ").unwrap_or(wanted);
2446 if wanted.is_empty() {
2447 return false;
2448 }
2449 let line = version.split(';').next().unwrap_or(version).trim();
2450 let line = line.strip_prefix("rustc ").unwrap_or(line);
2451 line == wanted || line.split_whitespace().next() == Some(wanted)
2452}
2453
2454fn rustc_version_of(cas: &LocalCas, action: &CacheDigest) -> Option<String> {
2456 let bytes = read_result_untouched(&cas.path_for(action).ok()?).ok()?;
2457 let descriptor: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
2458 if descriptor.get("kind")?.as_str()? != "rustc" {
2459 return None;
2460 }
2461 Some(
2462 descriptor
2463 .pointer("/compiler/rustc_version")?
2464 .as_str()?
2465 .to_owned(),
2466 )
2467}
2468
2469fn prune_import_staging(store: &Path, dry_run: bool) {
2481 if dry_run {
2482 return;
2483 }
2484 let root = store.join(IMPORT_STAGING_DIR);
2485 let Ok(entries) = read_dir_or_empty(&root) else {
2486 return;
2487 };
2488 for entry in entries {
2489 let Ok(metadata) = entry.metadata() else {
2490 continue;
2491 };
2492 let abandoned = metadata
2493 .modified()
2494 .ok()
2495 .and_then(|modified| SystemTime::now().duration_since(modified).ok())
2496 .is_some_and(|age| age >= IMPORT_STAGING_RETENTION);
2497 if !abandoned {
2498 continue;
2499 }
2500 let path = entry.path();
2501 if !metadata.is_dir() {
2502 if path
2505 .extension()
2506 .is_some_and(|extension| extension == "lock")
2507 && !claimed_staging_path(&path).exists()
2508 {
2509 let _ = std::fs::remove_file(&path);
2510 }
2511 continue;
2512 }
2513 let lock_path = import_staging_lock_path(&path);
2519 let Ok(mut lock) = fslock::LockFile::open(&lock_path) else {
2520 continue;
2521 };
2522 if !matches!(lock.try_lock(), Ok(true)) {
2523 continue;
2524 }
2525 if let Err(error) = std::fs::remove_dir_all(&path) {
2526 log::debug!(
2527 "could not remove abandoned import staging {}: {error}",
2528 path.display()
2529 );
2530 continue;
2531 }
2532 drop(lock);
2533 let _ = std::fs::remove_file(&lock_path);
2534 }
2535}
2536
2537fn claimed_staging_path(lock: &Path) -> PathBuf {
2539 lock.with_extension("")
2540}
2541
2542fn import_staging_lock_path(staging: &Path) -> PathBuf {
2547 let mut name = staging.as_os_str().to_os_string();
2548 name.push(".lock");
2549 PathBuf::from(name)
2550}
2551
2552struct StagingClaim {
2558 lock: Option<fslock::LockFile>,
2559 path: PathBuf,
2560}
2561
2562impl Drop for StagingClaim {
2563 fn drop(&mut self) {
2564 drop(self.lock.take());
2567 let _ = std::fs::remove_file(&self.path);
2568 }
2569}
2570
2571fn lock_import_staging(staging: &Path) -> Result<StagingClaim> {
2573 let path = import_staging_lock_path(staging);
2574 let mut lock = fslock::LockFile::open(&path)
2575 .wrap_err_with(|| format!("failed to open {}", path.display()))?;
2576 lock.lock()?;
2577 Ok(StagingClaim {
2578 lock: Some(lock),
2579 path,
2580 })
2581}
2582
2583#[derive(Debug, Default, PartialEq, Eq)]
2584struct SessionPrune {
2585 removed_streams: u64,
2586 removed_bytes: u64,
2587}
2588
2589fn prune_sessions(store: &Path, dry_run: bool) -> Result<SessionPrune> {
2590 let mut prune = SessionPrune::default();
2591 let ids = events::session_ids(store);
2592 let surplus = ids.len().saturating_sub(MAX_SESSIONS);
2593 for (index, id) in ids.iter().enumerate() {
2594 let paths = events::session_paths(store, id);
2595 let metadata = match std::fs::metadata(&paths.events) {
2596 Ok(metadata) => metadata,
2597 Err(_) => continue,
2598 };
2599 let stale = metadata
2600 .modified()
2601 .ok()
2602 .and_then(|at| SystemTime::now().duration_since(at).ok())
2603 .is_some_and(|age| age > SESSION_RETENTION);
2604 if index >= surplus && !stale {
2605 continue;
2606 }
2607 if events::session_is_live(store, id) {
2608 continue;
2609 }
2610 if dry_run {
2611 prune.removed_streams += 1;
2612 prune.removed_bytes += metadata.len();
2613 continue;
2614 }
2615 if matches!(remove(&paths.events)?, Removal::Removed) {
2616 prune.removed_streams += 1;
2617 prune.removed_bytes += metadata.len();
2618 let _ = std::fs::remove_file(&paths.lock);
2619 }
2620 }
2621 if !dry_run {
2622 for lock in events::orphaned_locks(store) {
2623 let _ = std::fs::remove_file(lock);
2624 }
2625 }
2626 Ok(prune)
2627}
2628
2629pub fn sweep_if_due(store: &Path, max_bytes: u64, interval: Duration) -> Result<Option<GcOutcome>> {
2636 if !claim_sweep(store, interval)? {
2637 return Ok(None);
2638 }
2639 gc(store, max_bytes).map(Some)
2640}
2641
2642pub fn claim_sweep(store: &Path, interval: Duration) -> Result<bool> {
2651 let lock_path = store.join(SWEEP_LOCK);
2652 std::fs::create_dir_all(lock_path.parent().expect("sweep lock has a parent"))?;
2653 let mut lock = fslock::LockFile::open(&lock_path)?;
2654 lock.lock()?;
2655
2656 let stamp = store.join(SWEEP_STAMP);
2657 if sweep_stamp_is_fresh(&stamp, interval) {
2658 return Ok(false);
2659 }
2660 record_sweep(store)?;
2661 Ok(true)
2662}
2663
2664pub fn record_sweep(store: &Path) -> Result<()> {
2670 let path = store.join(SWEEP_STAMP);
2671 std::fs::create_dir_all(path.parent().expect("the sweep stamp has a parent"))?;
2672 let stamp = std::fs::OpenOptions::new()
2673 .create(true)
2674 .write(true)
2675 .truncate(false)
2676 .open(path)?;
2677 stamp.set_modified(SystemTime::now())?;
2678 Ok(())
2679}
2680
2681pub fn sweep_is_due(store: &Path, interval: Duration) -> bool {
2688 !sweep_stamp_is_fresh(&store.join(SWEEP_STAMP), interval)
2689}
2690
2691fn sweep_stamp_is_fresh(stamp: &Path, interval: Duration) -> bool {
2692 std::fs::metadata(stamp)
2693 .and_then(|metadata| metadata.modified())
2694 .ok()
2695 .and_then(|modified| modified.elapsed().ok())
2696 .is_some_and(|since| since < interval)
2697}
2698
2699struct CheckoutScan {
2701 live_identities: BTreeSet<String>,
2702 live_records: u64,
2703 stale_records: Vec<PathBuf>,
2704}
2705
2706fn scan_checkouts(store: &Path) -> Result<CheckoutScan> {
2707 let root = store.join(CHECKOUTS_DIR);
2708 let mut scan = CheckoutScan {
2709 live_identities: BTreeSet::new(),
2710 live_records: 0,
2711 stale_records: Vec::new(),
2712 };
2713 let listing = match std::fs::read_dir(&root) {
2714 Ok(listing) => listing,
2715 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(scan),
2716 Err(error) => {
2717 return Err(error).wrap_err_with(|| format!("failed to read {}", root.display()));
2718 }
2719 };
2720 for entry in listing {
2721 let entry = entry?;
2722 let name = entry.file_name().to_string_lossy().into_owned();
2723 if !is_task_identity(&name) || !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
2729 continue;
2730 }
2731 for record in walk_files(&entry.path())? {
2732 match read_checkout_record(&record.path) {
2733 Some(checkout) if claim_is_live(store, &checkout) => {
2734 scan.live_identities.insert(name.clone());
2735 scan.live_records += 1;
2736 }
2737 Some(_) => scan.stale_records.push(record.path),
2738 None => {}
2741 }
2742 }
2743 }
2744 Ok(scan)
2745}
2746
2747fn read_checkout_record(path: &Path) -> Option<CheckoutRecord> {
2748 let bytes = std::fs::read(path).ok()?;
2749 let record = serde_json::from_slice::<CheckoutRecord>(&bytes).ok()?;
2750 (record.version == CHECKOUT_RECORD_VERSION).then_some(record)
2751}
2752
2753fn grouped_receipt_actions(store: &Path) -> Result<BTreeSet<CacheDigest>> {
2755 let root = store.join(BUILD_RECEIPTS_DIR).join("groups");
2756 Ok(walk_files(&root)?
2757 .into_iter()
2758 .filter_map(|entry| read_build_receipt(&entry.path))
2759 .flat_map(|receipt| receipt.predictions.into_iter())
2760 .map(|prediction| prediction.action)
2761 .collect())
2762}
2763
2764fn claim_is_live(store: &Path, record: &CheckoutRecord) -> bool {
2767 checkout_is_live_on(store, &record.workspace_root) && !claim_has_expired(record)
2768}
2769
2770fn claim_has_expired(record: &CheckoutRecord) -> bool {
2771 let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
2772 return false;
2773 };
2774 now.as_secs()
2775 .saturating_sub(record.updated_secs)
2776 .gt(&CHECKOUT_RETENTION.as_secs())
2777}
2778
2779pub fn checkout_is_live_on(store: &Path, workspace_root: &Path) -> bool {
2792 if !matches!(workspace_root.try_exists(), Ok(false)) {
2793 return true;
2794 }
2795
2796 let Ok(store_metadata) = std::fs::metadata(store) else {
2797 return true;
2798 };
2799 for ancestor in workspace_root.ancestors().skip(1) {
2800 match std::fs::metadata(ancestor) {
2801 Ok(ancestor_metadata) => {
2802 return !same_filesystem(store, &store_metadata, ancestor, &ancestor_metadata);
2803 }
2804 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2805 Err(_) => return true,
2806 }
2807 }
2808 true
2809}
2810
2811pub fn checkout_is_live(workspace_root: &Path) -> bool {
2817 let Some(parent) = workspace_root.parent() else {
2818 return true;
2819 };
2820 checkout_is_live_on(parent, workspace_root)
2821}
2822
2823pub fn same_filesystem_paths(a: &Path, b: &Path) -> bool {
2829 let (Ok(a_metadata), Ok(b_metadata)) = (std::fs::metadata(a), std::fs::metadata(b)) else {
2830 return false;
2831 };
2832 same_filesystem(a, &a_metadata, b, &b_metadata)
2833}
2834
2835#[cfg(unix)]
2836fn same_filesystem(
2837 _a_path: &Path,
2838 a: &std::fs::Metadata,
2839 _b_path: &Path,
2840 b: &std::fs::Metadata,
2841) -> bool {
2842 use std::os::unix::fs::MetadataExt as _;
2843 a.dev() == b.dev()
2844}
2845
2846#[cfg(windows)]
2847fn same_filesystem(
2848 a_path: &Path,
2849 _a: &std::fs::Metadata,
2850 b_path: &Path,
2851 _b: &std::fs::Metadata,
2852) -> bool {
2853 windows_volume_name(a_path).is_some_and(|volume| windows_volume_name(b_path) == Some(volume))
2854}
2855
2856#[cfg(windows)]
2857fn windows_volume_name(path: &Path) -> Option<Vec<u16>> {
2858 use std::os::windows::ffi::OsStrExt as _;
2859 use windows_sys::Win32::Storage::FileSystem::{
2860 GetVolumeNameForVolumeMountPointW, GetVolumePathNameW,
2861 };
2862
2863 let path = path
2864 .as_os_str()
2865 .encode_wide()
2866 .chain(Some(0))
2867 .collect::<Vec<_>>();
2868 let mut mount = vec![0_u16; 261];
2872 if unsafe { GetVolumePathNameW(path.as_ptr(), mount.as_mut_ptr(), mount.len() as u32) } == 0 {
2875 return None;
2876 }
2877 let mut volume = vec![0_u16; 261];
2878 if unsafe {
2881 GetVolumeNameForVolumeMountPointW(mount.as_ptr(), volume.as_mut_ptr(), volume.len() as u32)
2882 } == 0
2883 {
2884 return None;
2885 }
2886 volume.truncate(volume.iter().position(|unit| *unit == 0)?);
2887 Some(volume)
2888}
2889
2890#[cfg(not(any(unix, windows)))]
2891fn same_filesystem(
2892 _a_path: &Path,
2893 _a: &std::fs::Metadata,
2894 _b_path: &Path,
2895 _b: &std::fs::Metadata,
2896) -> bool {
2897 false
2898}
2899
2900fn rooted_objects(store: &Path, identities: &BTreeSet<String>) -> Result<HashSet<PathBuf>> {
2908 let mut actions = BTreeSet::new();
2909 for identity in identities {
2910 let manifest_actions = match task_manifest_actions(store, identity) {
2913 Ok(actions) => actions,
2914 Err(error) => {
2915 log::debug!("could not read the manifest for {identity}: {error}");
2916 continue;
2917 }
2918 };
2919 actions.extend(manifest_actions);
2920 }
2921 Ok(rooted_action_objects(store, actions))
2922}
2923
2924fn rooted_action_objects(
2926 store: &Path,
2927 actions: impl IntoIterator<Item = CacheDigest>,
2928) -> HashSet<PathBuf> {
2929 let cas = LocalCas::new(store);
2930 let mut rooted = HashSet::new();
2931 let mut visited = BTreeSet::new();
2932 for action in actions {
2933 let Some(result) = read_action_result(store, &action) else {
2934 continue;
2935 };
2936 root_digest(&cas, &mut rooted, &result.action);
2937 if let Some(metadata) = &result.metadata {
2938 root_digest(&cas, &mut rooted, metadata);
2939 if let Some(rustc) = read_rustc_metadata(&cas, metadata) {
2946 root_digest(&cas, &mut rooted, &rustc.stdout);
2947 root_digest(&cas, &mut rooted, &rustc.stderr);
2948 }
2949 }
2950 if let Some(output_root) = &result.output_root {
2951 root_digest(&cas, &mut rooted, output_root);
2952 root_tree(&cas, output_root, &mut rooted, &mut visited);
2953 }
2954 }
2955 rooted
2956}
2957
2958fn read_action_result(store: &Path, action: &CacheDigest) -> Option<RemoteActionResult> {
2959 let path = mbx_cache_core::LocalActionCache::new(store)
2960 .path_for(action)
2961 .ok()?;
2962 let bytes = read_result_untouched(&path).ok()?;
2963 serde_json::from_slice(&bytes).ok()
2964}
2965
2966fn root_tree(
2967 cas: &LocalCas,
2968 output_root: &CacheDigest,
2969 rooted: &mut HashSet<PathBuf>,
2970 visited: &mut BTreeSet<CacheDigest>,
2971) {
2972 let mut pending = vec![output_root.clone()];
2973 while let Some(digest) = pending.pop() {
2974 if !visited.insert(digest.clone()) {
2975 continue;
2976 }
2977 let Some(directory) = read_directory(cas, &digest) else {
2978 continue;
2979 };
2980 for file in &directory.files {
2981 root_digest(cas, rooted, &file.digest);
2982 }
2983 for child in &directory.directories {
2984 root_digest(cas, rooted, &child.digest);
2985 pending.push(child.digest.clone());
2986 }
2987 }
2988}
2989
2990fn read_rustc_metadata(cas: &LocalCas, digest: &CacheDigest) -> Option<RustcMetadata> {
2991 let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2992 serde_json::from_slice(&bytes).ok()
2993}
2994
2995fn read_directory(cas: &LocalCas, digest: &CacheDigest) -> Option<CacheDirectory> {
2996 let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2997 serde_json::from_slice(&bytes).ok()
2998}
2999
3000fn root_digest(cas: &LocalCas, rooted: &mut HashSet<PathBuf>, digest: &CacheDigest) {
3001 if let Ok(path) = cas.path_for(digest) {
3002 rooted.insert(path);
3003 }
3004}
3005
3006#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3007enum Removal {
3008 Removed,
3009 Missing,
3010 Blocked,
3011}
3012
3013fn remove(path: &Path) -> Result<Removal> {
3014 match std::fs::remove_file(path) {
3015 Ok(()) => Ok(Removal::Removed),
3016 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Removal::Missing),
3018 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
3023 log::debug!("could not evict {}: {error}", path.display());
3024 Ok(Removal::Blocked)
3025 }
3026 Err(error) => Err(error).wrap_err_with(|| format!("failed to evict {}", path.display())),
3027 }
3028}
3029
3030#[derive(Debug, Default, PartialEq, Eq)]
3031struct ObjectEvictions {
3032 removed_objects: u64,
3033 removed_bytes: u64,
3034 remaining_bytes: u64,
3035}
3036
3037fn evict_objects(
3040 objects: &[Entry],
3041 rooted: &HashSet<PathBuf>,
3042 mut live_bytes: u64,
3043 max_bytes: u64,
3044 mut remove_file: impl FnMut(&Path) -> Result<Removal>,
3045) -> Result<ObjectEvictions> {
3046 let mut outcome = ObjectEvictions::default();
3047 let mut blocked_unrooted = false;
3048 for entry in objects {
3049 if live_bytes <= max_bytes {
3050 break;
3051 }
3052 let is_rooted = rooted.contains(&entry.path);
3053 if is_rooted && blocked_unrooted {
3054 break;
3058 }
3059 match remove_file(&entry.path)? {
3060 Removal::Removed => {
3061 live_bytes = live_bytes.saturating_sub(entry.size);
3062 outcome.removed_objects += 1;
3063 outcome.removed_bytes += entry.size;
3064 }
3065 Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
3066 Removal::Blocked if !is_rooted => blocked_unrooted = true,
3067 Removal::Blocked => {}
3068 }
3069 }
3070 outcome.remaining_bytes = live_bytes;
3071 Ok(outcome)
3072}
3073
3074fn action_result_is_dangling(
3096 cas: &LocalCas,
3097 path: &Path,
3098 virtually_removed: &HashSet<PathBuf>,
3099 walk_tree: bool,
3100 trees: &mut HashMap<CacheDigest, bool>,
3101) -> Result<bool> {
3102 let bytes = match read_result_untouched(path) {
3103 Ok(bytes) => bytes,
3104 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
3105 Err(error) => {
3106 return Err(error).wrap_err_with(|| format!("failed to read {}", path.display()));
3107 }
3108 };
3109 action_result_bytes_are_dangling(cas, &bytes, virtually_removed, walk_tree, trees)
3110}
3111
3112fn held_by_another_process(error: &std::io::Error) -> bool {
3119 error.kind() == std::io::ErrorKind::PermissionDenied
3120 || (cfg!(windows) && matches!(error.raw_os_error(), Some(32 | 33)))
3121}
3122
3123fn action_result_bytes_are_dangling(
3124 cas: &LocalCas,
3125 bytes: &[u8],
3126 virtually_removed: &HashSet<PathBuf>,
3127 walk_tree: bool,
3128 trees: &mut HashMap<CacheDigest, bool>,
3129) -> Result<bool> {
3130 let Ok(result) = serde_json::from_slice::<RemoteActionResult>(bytes) else {
3132 return Ok(false);
3133 };
3134 let missing = |digest: &CacheDigest| match cas.path_for(digest) {
3135 Ok(path) => virtually_removed.contains(&path) || matches!(path.try_exists(), Ok(false)),
3137 Err(_) => true,
3139 };
3140 for digest in [
3141 Some(&result.action),
3142 result.metadata.as_ref(),
3143 result.output_root.as_ref(),
3144 ]
3145 .into_iter()
3146 .flatten()
3147 {
3148 if missing(digest) {
3149 return Ok(true);
3150 }
3151 }
3152 if walk_tree && let Some(output_root) = &result.output_root {
3153 return Ok(!tree_is_complete(cas, output_root, &missing, trees));
3154 }
3155 Ok(false)
3156}
3157
3158fn tree_is_complete(
3161 cas: &LocalCas,
3162 digest: &CacheDigest,
3163 missing: &impl Fn(&CacheDigest) -> bool,
3164 trees: &mut HashMap<CacheDigest, bool>,
3165) -> bool {
3166 if let Some(&complete) = trees.get(digest) {
3167 return complete;
3168 }
3169 trees.insert(digest.clone(), true);
3172 let complete = read_directory(cas, digest).is_none_or(|directory| {
3174 directory.files.iter().all(|file| !missing(&file.digest))
3175 && directory.directories.iter().all(|child| {
3176 !missing(&child.digest) && tree_is_complete(cas, &child.digest, missing, trees)
3177 })
3178 });
3179 trees.insert(digest.clone(), complete);
3180 complete
3181}
3182
3183struct Entry {
3184 path: PathBuf,
3185 size: u64,
3186 used: SystemTime,
3187}
3188
3189fn walk_files(root: &Path) -> Result<Vec<Entry>> {
3190 let mut entries = Vec::new();
3191 let mut pending = vec![root.to_path_buf()];
3192 while let Some(directory) = pending.pop() {
3193 let listing = match std::fs::read_dir(&directory) {
3194 Ok(listing) => listing,
3195 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
3196 Err(error) => {
3197 return Err(error)
3198 .wrap_err_with(|| format!("failed to read {}", directory.display()));
3199 }
3200 };
3201 for entry in listing {
3202 let entry = entry?;
3203 let metadata = match entry.metadata() {
3204 Ok(metadata) => metadata,
3205 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
3207 Err(error) => return Err(error.into()),
3208 };
3209 if metadata.is_dir() {
3210 pending.push(entry.path());
3211 } else if metadata.is_file() {
3212 let used = metadata
3213 .accessed()
3214 .or_else(|_| metadata.modified())
3215 .unwrap_or(SystemTime::UNIX_EPOCH);
3216 entries.push(Entry {
3217 path: entry.path(),
3218 size: metadata.len(),
3219 used,
3220 });
3221 }
3222 }
3223 }
3224 Ok(entries)
3225}
3226
3227fn read_dir_or_empty(root: &Path) -> Result<Vec<std::fs::DirEntry>> {
3228 match std::fs::read_dir(root) {
3229 Ok(entries) => entries
3230 .collect::<std::io::Result<Vec<_>>>()
3231 .map_err(Into::into),
3232 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
3233 Err(error) => Err(error).wrap_err_with(|| format!("failed to read {}", root.display())),
3234 }
3235}
3236
3237fn digest_from_path(path: &Path, action_result: bool) -> Option<CacheDigest> {
3238 let name = path.file_name()?.to_str()?;
3239 let name = if action_result {
3240 name.strip_suffix(".json")?
3241 } else {
3242 name
3243 };
3244 let (hash, size) = name.rsplit_once('-')?;
3245 let algorithm = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
3246 let digest = CacheDigest {
3247 algorithm,
3248 hash: hash.to_string(),
3249 size: size.parse().ok()?,
3250 };
3251 digest.validate().ok()?;
3252 Some(digest)
3253}
3254
3255fn addressed_digest(store: &Path, path: &Path, action_result: bool) -> Option<CacheDigest> {
3256 let digest = digest_from_path(path, action_result)?;
3257 let canonical = if action_result {
3258 mbx_cache_core::LocalActionCache::new(store)
3259 .path_for(&digest)
3260 .ok()?
3261 } else {
3262 LocalCas::new(store).path_for(&digest).ok()?
3263 };
3264 (canonical == path).then_some(digest)
3265}
3266
3267fn cached_tree_bytes(cache: &mut BTreeMap<PathBuf, u64>, root: &Path) -> u64 {
3268 *cache
3269 .entry(root.to_path_buf())
3270 .or_insert_with(|| tree_bytes(root))
3271}
3272
3273fn tree_bytes(root: &Path) -> u64 {
3274 let Ok(root_metadata) = std::fs::metadata(root) else {
3275 return 0;
3276 };
3277 if root_metadata.is_file() {
3278 return root_metadata.len();
3279 }
3280 let Ok(entries) = std::fs::read_dir(root) else {
3281 return 0;
3282 };
3283 let mut bytes = 0_u64;
3284 let mut pending = entries
3285 .filter_map(std::result::Result::ok)
3286 .map(|entry| entry.path())
3287 .collect::<Vec<_>>();
3288 while let Some(path) = pending.pop() {
3289 let Ok(metadata) = std::fs::symlink_metadata(&path) else {
3290 continue;
3291 };
3292 if metadata.is_file() {
3293 bytes = bytes.saturating_add(metadata.len());
3294 } else if metadata.is_dir()
3295 && let Ok(entries) = std::fs::read_dir(path)
3296 {
3297 pending.extend(
3298 entries
3299 .filter_map(std::result::Result::ok)
3300 .map(|entry| entry.path()),
3301 );
3302 }
3303 }
3304 bytes
3305}
3306
3307#[cfg(test)]
3308#[path = "store_tests.rs"]
3309mod tests;
3310
3311fn write_advisory(path: &Path, contents: &[u8]) -> Result<()> {
3314 let parent = path
3315 .parent()
3316 .filter(|parent| !parent.as_os_str().is_empty())
3317 .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
3318 std::fs::create_dir_all(parent)
3319 .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
3320 let mut temporary = tempfile::Builder::new()
3321 .prefix(".mbx-")
3322 .tempfile_in(parent)?;
3323 use std::io::Write as _;
3324 temporary.write_all(contents)?;
3325 temporary
3326 .persist(path)
3327 .map_err(|error| error.error)
3328 .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
3329 Ok(())
3330}
3331
3332fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
3333 let parent = path
3334 .parent()
3335 .filter(|parent| !parent.as_os_str().is_empty())
3336 .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
3337 std::fs::create_dir_all(parent)
3338 .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
3339 let mut temporary = tempfile::Builder::new()
3340 .prefix(".mbx-")
3341 .tempfile_in(parent)?;
3342 use std::io::Write as _;
3343 temporary.write_all(contents)?;
3344 temporary.as_file_mut().sync_all()?;
3345 temporary
3346 .persist(path)
3347 .map_err(|error| error.error)
3348 .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
3349 Ok(())
3350}