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::rc::Rc;
23use std::sync::Mutex;
24use std::sync::atomic::{AtomicUsize, Ordering};
25use std::time::{Duration, SystemTime, UNIX_EPOCH};
26
27const CAS_DIR: &str = "cas/v1";
28const ACTION_RESULTS_DIR: &str = "action-results/v1";
29const CHECKOUTS_DIR: &str = "checkouts/v1";
30const SWEEP_STAMP: &str = "gc/v1/last-sweep";
31const SWEEP_LOCK: &str = "gc/v1/sweep.lock";
32const CHECKOUT_RECORD_VERSION: u8 = 1;
33const BUILD_RECEIPTS_DIR: &str = "build-receipts/v1";
34const BUILD_RECEIPT_VERSION: u8 = 1;
35const IMPORT_STAGING_DIR: &str = "import-staging";
36const EXPORT_MANIFEST: &str = "mbx-cache-export-v1.json";
37const EXPORT_VERSION: u8 = 2;
38const LEGACY_EXPORT_VERSION: u8 = 1;
39
40const IMPORT_STAGING_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
41const SESSION_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
42const MAX_SESSIONS: usize = 256;
43
44const CHECKOUT_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60);
54
55#[derive(Debug, Default, PartialEq, Eq)]
56pub struct StoreStats {
57 pub objects: u64,
58 pub object_bytes: u64,
59 pub action_results: u64,
60 pub action_result_bytes: u64,
61 pub live_checkouts: u64,
62 pub stale_checkouts: u64,
67}
68
69impl StoreStats {
70 pub fn total_bytes(&self) -> u64 {
71 self.object_bytes.saturating_add(self.action_result_bytes)
72 }
73}
74
75#[derive(Debug, Default, PartialEq, Eq)]
76pub struct GcOutcome {
77 pub removed_objects: u64,
78 pub removed_action_results: u64,
79 pub removed_checkout_records: u64,
80 pub removed_session_streams: u64,
83 pub removed_bytes: u64,
84 pub remaining_bytes: u64,
85}
86
87#[derive(Debug, PartialEq, Eq)]
88pub struct ProjectUsage {
89 pub workspace_root: PathBuf,
90 pub identities: u64,
91 pub action_bytes: u64,
92 pub target_bytes: u64,
93 pub live: bool,
94}
95
96#[derive(Debug, PartialEq, Eq)]
97pub struct LargestEntry {
98 pub kind: &'static str,
99 pub path: PathBuf,
100 pub bytes: u64,
101}
102
103#[derive(Debug, Default, PartialEq, Eq)]
104pub struct VerifyOutcome {
105 pub checked_objects: u64,
106 pub checked_action_results: u64,
107 pub problems: Vec<PathBuf>,
108}
109
110#[derive(Debug, Default, PartialEq, Eq)]
111pub struct RemoveProjectOutcome {
112 pub removed_checkout_records: u64,
113}
114
115#[derive(Debug, Default, PartialEq, Eq)]
116pub struct TransferOutcome {
117 pub actions: u64,
118 pub objects: u64,
119 pub bytes: u64,
120}
121
122#[derive(Debug)]
124#[non_exhaustive]
125pub struct ImportOutcome {
126 pub transfer: TransferOutcome,
127 pub attachments: BTreeMap<String, CacheDigest>,
128}
129
130#[derive(Debug, Default)]
132pub struct ExportAdditions {
133 pub attachments: BTreeMap<String, CacheDigest>,
135 pub objects: BTreeSet<CacheDigest>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
141pub struct WorkspaceTarget {
142 pub workspace_root: PathBuf,
143 pub target_dir: PathBuf,
144}
145
146#[derive(Debug, Serialize, Deserialize)]
147#[serde(deny_unknown_fields)]
148struct ExportManifest {
149 version: u8,
150 tasks: Vec<TaskActionManifest>,
151 actions: Vec<CacheDigest>,
152 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
153 attachments: BTreeMap<String, CacheDigest>,
154 #[serde(default, skip_serializing_if = "Vec::is_empty")]
155 objects: Vec<CacheDigest>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161struct BuildReceipt {
162 version: u8,
163 workspace_root: PathBuf,
164 identity: String,
165 completed_nanos: u64,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 group: Option<String>,
168 predictions: Vec<ActionPrediction>,
169}
170
171pub fn record_build_receipt(
173 store: &Path,
174 run: &str,
175 identity: &str,
176 workspace_root: &Path,
177 group: Option<&str>,
178 predictions: Vec<ActionPrediction>,
179) -> Result<()> {
180 if !is_task_identity(run) || !is_task_identity(identity) {
181 eyre::bail!("invalid build receipt identity");
182 }
183 if !(TaskActionManifest {
184 version: 1,
185 task: identity.to_owned(),
186 predictions: predictions.clone(),
187 })
188 .validate()
189 {
190 eyre::bail!("invalid build receipt prediction");
191 }
192 if let Some(group) = group {
193 validate_export_group(group)?;
194 }
195 if group.is_none()
200 && read_build_receipt(&latest_receipt_path(store, workspace_root)).is_some_and(|latest| {
201 latest.identity == identity
202 && latest.workspace_root == workspace_root
203 && latest.group.is_none()
204 && latest.predictions == predictions
205 })
206 {
207 return Ok(());
208 }
209 let key = workspace_key(workspace_root);
210 let lock_path = store
211 .join(BUILD_RECEIPTS_DIR)
212 .join("locks")
213 .join(format!("{key}.lock"));
214 std::fs::create_dir_all(lock_path.parent().expect("receipt lock has a parent"))?;
215 let mut lock = fslock::LockFile::open(&lock_path)?;
216 lock.lock()?;
217 let completed_nanos = SystemTime::now()
218 .duration_since(UNIX_EPOCH)
219 .map(|elapsed| elapsed.as_nanos().try_into().unwrap_or(u64::MAX))
220 .unwrap_or_default();
221 let receipt = BuildReceipt {
222 version: BUILD_RECEIPT_VERSION,
223 workspace_root: workspace_root.to_path_buf(),
224 identity: identity.to_owned(),
225 completed_nanos,
226 group: group.map(str::to_owned),
227 predictions,
228 };
229 let bytes = serde_json::to_vec(&receipt)?;
230 write_atomic(&latest_receipt_path(store, workspace_root), &bytes)?;
231 if let Some(group) = group {
232 write_atomic(&group_receipt_path(store, group, run), &bytes)?;
233 }
234 Ok(())
235}
236
237fn workspace_key(workspace_root: &Path) -> String {
238 CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash
239}
240
241fn latest_receipt_path(store: &Path, workspace_root: &Path) -> PathBuf {
242 store
243 .join(BUILD_RECEIPTS_DIR)
244 .join("checkouts")
245 .join(format!("{}.json", workspace_key(workspace_root)))
246}
247
248fn group_receipt_path(store: &Path, group: &str, run: &str) -> PathBuf {
249 store
250 .join(BUILD_RECEIPTS_DIR)
251 .join("groups")
252 .join(group_key(group))
253 .join(format!("{run}.json"))
254}
255
256fn group_key(group: &str) -> String {
257 CacheDigest::blake3(group.as_bytes()).hash
258}
259
260fn read_build_receipt(path: &Path) -> Option<BuildReceipt> {
261 let bytes = std::fs::read(path).ok()?;
262 let receipt = serde_json::from_slice::<BuildReceipt>(&bytes).ok()?;
263 let valid = TaskActionManifest {
264 version: 1,
265 task: receipt.identity.clone(),
266 predictions: receipt.predictions.clone(),
267 }
268 .validate();
269 (receipt.version == BUILD_RECEIPT_VERSION && valid).then_some(receipt)
270}
271
272fn validate_export_group(group: &str) -> Result<()> {
273 if group.is_empty() || group.len() > 256 || group.chars().any(char::is_control) {
274 eyre::bail!("invalid build export group");
275 }
276 Ok(())
277}
278
279#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
287pub enum ExportForm {
288 #[default]
290 Tar,
291 Directory,
293}
294
295impl std::str::FromStr for ExportForm {
296 type Err = eyre::Report;
297
298 fn from_str(text: &str) -> Result<Self> {
299 match text {
300 "tar" => Ok(Self::Tar),
301 "directory" => Ok(Self::Directory),
302 other => eyre::bail!("unknown export format {other:?}; expected tar or directory"),
303 }
304 }
305}
306
307pub fn export_checkout(
309 store: &Path,
310 workspace_root: &Path,
311 archive: &Path,
312) -> Result<TransferOutcome> {
313 let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
314 .filter(|receipt| receipt.workspace_root == workspace_root)
315 .ok_or_else(|| {
316 eyre::eyre!(
317 "no completed mbx build is recorded for {}",
318 workspace_root.display()
319 )
320 })?;
321 export_receipts(
322 store,
323 vec![receipt],
324 archive,
325 ExportAdditions::default(),
326 ExportForm::Tar,
327 )
328}
329
330pub fn checkout_workspace_target(
332 store: &Path,
333 workspace_root: &Path,
334) -> Result<Option<WorkspaceTarget>> {
335 let Some(receipt) = read_build_receipt(&latest_receipt_path(store, workspace_root))
336 .filter(|receipt| receipt.workspace_root == workspace_root)
337 else {
338 return Ok(None);
339 };
340 Ok(workspace_target_for_receipt(store, &receipt))
341}
342
343pub fn group_workspace_targets(store: &Path, group: &str) -> Result<Vec<WorkspaceTarget>> {
345 validate_export_group(group)?;
346 let root = store
347 .join(BUILD_RECEIPTS_DIR)
348 .join("groups")
349 .join(group_key(group));
350 let targets = walk_files(&root)?
351 .into_iter()
352 .filter_map(|entry| read_build_receipt(&entry.path))
353 .filter(|receipt| receipt.group.as_deref() == Some(group))
354 .filter_map(|receipt| workspace_target_for_receipt(store, &receipt))
355 .collect::<BTreeSet<_>>();
356 Ok(targets.into_iter().collect())
357}
358
359fn workspace_target_for_receipt(store: &Path, receipt: &BuildReceipt) -> Option<WorkspaceTarget> {
360 let record = read_checkout_record(&checkout_record_path(
361 store,
362 &receipt.identity,
363 &receipt.workspace_root,
364 ))?;
365 (record.workspace_root == receipt.workspace_root && record.target_dir != record.workspace_root)
366 .then_some(WorkspaceTarget {
367 workspace_root: record.workspace_root,
368 target_dir: record.target_dir,
369 })
370}
371
372pub fn export_checkout_with(
374 store: &Path,
375 workspace_root: &Path,
376 archive: &Path,
377 additions: ExportAdditions,
378) -> Result<TransferOutcome> {
379 let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
380 .filter(|receipt| receipt.workspace_root == workspace_root)
381 .ok_or_else(|| {
382 eyre::eyre!(
383 "no completed mbx build is recorded for {}",
384 workspace_root.display()
385 )
386 })?;
387 export_receipts(store, vec![receipt], archive, additions, ExportForm::Tar)
388}
389
390pub fn export_checkout_as(
392 store: &Path,
393 workspace_root: &Path,
394 destination: &Path,
395 additions: ExportAdditions,
396 form: ExportForm,
397) -> Result<TransferOutcome> {
398 let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
399 .filter(|receipt| receipt.workspace_root == workspace_root)
400 .ok_or_else(|| {
401 eyre::eyre!(
402 "no completed mbx build is recorded for {}",
403 workspace_root.display()
404 )
405 })?;
406 export_receipts(store, vec![receipt], destination, additions, form)
407}
408
409pub fn export_group(store: &Path, group: &str, archive: &Path) -> Result<TransferOutcome> {
411 export_group_with(store, group, archive, ExportAdditions::default())
412}
413
414pub fn export_group_with(
416 store: &Path,
417 group: &str,
418 archive: &Path,
419 additions: ExportAdditions,
420) -> Result<TransferOutcome> {
421 export_group_as(store, group, archive, additions, ExportForm::Tar)
422}
423
424pub fn export_group_as(
426 store: &Path,
427 group: &str,
428 destination: &Path,
429 additions: ExportAdditions,
430 form: ExportForm,
431) -> Result<TransferOutcome> {
432 validate_export_group(group)?;
433 let root = store
434 .join(BUILD_RECEIPTS_DIR)
435 .join("groups")
436 .join(group_key(group));
437 let receipts = walk_files(&root)?
438 .into_iter()
439 .filter_map(|entry| read_build_receipt(&entry.path).map(|receipt| (entry.path, receipt)))
440 .filter(|(_, receipt)| receipt.group.as_deref() == Some(group))
441 .collect::<Vec<_>>();
442 if receipts.is_empty() {
443 eyre::bail!("no completed mbx builds are recorded for export group {group:?}");
444 }
445 let outcome = export_receipts(
446 store,
447 receipts
448 .iter()
449 .map(|(_, receipt)| receipt.clone())
450 .collect(),
451 destination,
452 additions,
453 form,
454 )?;
455 for (path, _) in receipts {
460 std::fs::remove_file(path)?;
461 }
462 let _ = std::fs::remove_dir(root);
463 Ok(outcome)
464}
465
466fn export_receipts(
467 store: &Path,
468 mut receipts: Vec<BuildReceipt>,
469 archive: &Path,
470 additions: ExportAdditions,
471 form: ExportForm,
472) -> Result<TransferOutcome> {
473 receipts.sort_by(|left, right| {
474 left.completed_nanos
475 .cmp(&right.completed_nanos)
476 .then_with(|| {
477 fn key(prediction: &ActionPrediction) -> (&CacheDigest, &CacheDigest, &str, &str) {
480 (
481 &prediction.invocation,
482 &prediction.action,
483 &prediction.adapter,
484 &prediction.payload,
485 )
486 }
487 left.predictions
488 .iter()
489 .map(key)
490 .cmp(right.predictions.iter().map(key))
491 })
492 });
493 let mut actions = BTreeSet::new();
494 let mut tasks = BTreeMap::new();
495 for receipt in receipts {
496 actions.extend(
497 receipt
498 .predictions
499 .iter()
500 .map(|prediction| prediction.action.clone()),
501 );
502 tasks
505 .entry(receipt.identity)
506 .or_insert_with(Vec::new)
507 .extend(receipt.predictions);
508 }
509 let tasks = tasks
510 .into_iter()
511 .map(|(task, predictions)| {
512 let mut invocations = BTreeSet::new();
513 let mut predictions = predictions
514 .into_iter()
515 .rev()
516 .filter(|prediction| invocations.insert(prediction.invocation.clone()))
517 .collect::<Vec<_>>();
518 predictions.reverse();
519 TaskActionManifest {
520 version: 1,
521 task,
522 predictions,
523 }
524 })
525 .collect::<Vec<_>>();
526 if tasks.iter().any(|task| !task.validate()) {
527 eyre::bail!("combined export predictions exceed task manifest limits");
528 }
529 validate_export_additions(&additions)?;
530 let mut closure = strict_closure(store, &actions)?;
531 let cas = LocalCas::new(store);
532 for digest in &additions.objects {
533 require_object(&cas, &mut closure, digest)?;
534 }
535 verify_pending(&mut closure.pending).wrap_err("cache closure is incomplete or corrupt")?;
539 let parent = archive
540 .parent()
541 .filter(|parent| !parent.as_os_str().is_empty())
542 .unwrap_or_else(|| Path::new("."));
543 std::fs::create_dir_all(parent)?;
544 let manifest = serde_json::to_vec(&ExportManifest {
545 version: if additions.attachments.is_empty() && additions.objects.is_empty() {
546 LEGACY_EXPORT_VERSION
547 } else {
548 EXPORT_VERSION
549 },
550 tasks,
551 actions: actions.iter().cloned().collect(),
552 attachments: additions.attachments.clone(),
553 objects: additions.objects.iter().cloned().collect(),
554 })?;
555 let members = closure
556 .objects
557 .iter()
558 .chain(closure.results.iter())
559 .collect::<Vec<_>>();
560 let bytes = match form {
561 ExportForm::Tar => write_tar_export(store, archive, parent, &manifest, &members)?,
562 ExportForm::Directory => {
563 write_directory_export(store, archive, parent, &manifest, &members)?
564 }
565 };
566 Ok(TransferOutcome {
567 actions: actions.len() as u64,
568 objects: closure.objects.len() as u64,
569 bytes,
570 })
571}
572
573fn write_tar_export(
575 store: &Path,
576 archive: &Path,
577 parent: &Path,
578 manifest: &[u8],
579 members: &[&PathBuf],
580) -> Result<u64> {
581 let temporary = tempfile::Builder::new()
582 .prefix(".mbx-export-")
583 .tempfile_in(parent)?;
584 let mut builder = tar::Builder::new(temporary.reopen()?);
585 append_bytes(&mut builder, Path::new(EXPORT_MANIFEST), manifest)?;
586 for path in members {
587 append_file(&mut builder, store, path)?;
588 }
589 builder.finish()?;
590 drop(builder);
591 temporary
592 .persist(archive)
593 .map_err(|error| error.error)
594 .wrap_err_with(|| format!("failed to publish {}", archive.display()))?;
595 Ok(std::fs::metadata(archive)?.len())
596}
597
598fn write_directory_export(
604 store: &Path,
605 destination: &Path,
606 parent: &Path,
607 manifest: &[u8],
608 members: &[&PathBuf],
609) -> Result<u64> {
610 let staging = tempfile::Builder::new()
611 .prefix(".mbx-export-")
612 .tempdir_in(parent)?;
613 std::fs::write(staging.path().join(EXPORT_MANIFEST), manifest)?;
614 let mut bytes = manifest.len() as u64;
615 for path in members {
616 let relative = path.strip_prefix(store)?;
617 let target = staging.path().join(relative);
618 std::fs::create_dir_all(target.parent().expect("export member has a parent"))?;
619 bytes += std::fs::copy(path, &target)?;
620 }
621 let staged = staging.keep();
622 let retired = match std::fs::symlink_metadata(destination) {
627 Ok(_) => {
628 let holder = tempfile::Builder::new()
629 .prefix(".mbx-export-retired-")
630 .tempdir_in(parent)?;
631 let moved = holder.path().join("bundle");
632 std::fs::rename(destination, &moved)
633 .wrap_err_with(|| format!("failed to replace {}", destination.display()))?;
634 Some((holder, moved))
635 }
636 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
637 Err(error) => {
638 return Err(error)
639 .wrap_err_with(|| format!("failed to replace {}", destination.display()));
640 }
641 };
642 if let Err(error) = std::fs::rename(&staged, destination) {
643 let _ = std::fs::remove_dir_all(&staged);
644 if let Some((holder, moved)) = retired
645 && std::fs::rename(&moved, destination).is_err()
646 {
647 let kept = holder.keep();
652 log::warn!(
653 "could not restore {} after a failed export; its previous contents are at {}",
654 destination.display(),
655 kept.join("bundle").display()
656 );
657 }
658 return Err(error).wrap_err_with(|| format!("failed to publish {}", destination.display()));
659 }
660 drop(retired);
662 Ok(bytes)
663}
664
665pub fn import_archive(store: &Path, archive: &Path) -> Result<TransferOutcome> {
667 Ok(import_archive_with_attachments(store, archive)?.transfer)
668}
669
670pub fn import_archive_with_attachments(store: &Path, archive: &Path) -> Result<ImportOutcome> {
672 let staged;
676 let _staging_lock;
677 let root = if archive.is_dir() {
678 _staging_lock = None;
679 validate_directory_bundle(archive)?;
680 archive
681 } else {
682 let staging_root = store.join(IMPORT_STAGING_DIR);
688 std::fs::create_dir_all(&staging_root)?;
689 staged = tempfile::Builder::new()
690 .prefix("import-")
691 .tempdir_in(&staging_root)?;
692 _staging_lock = Some(lock_import_staging(staged.path())?);
697 unpack_archive(archive, staged.path())?;
698 staged.path()
699 };
700 let bundle_bytes = archive.is_dir().then(|| tree_bytes(archive));
703 let manifest: ExportManifest =
704 serde_json::from_slice(&std::fs::read(root.join(EXPORT_MANIFEST))?)?;
705 let actions = manifest.actions.iter().cloned().collect::<BTreeSet<_>>();
706 let task_identities = manifest
707 .tasks
708 .iter()
709 .map(|task| task.task.as_str())
710 .collect::<BTreeSet<_>>();
711 if !matches!(manifest.version, LEGACY_EXPORT_VERSION | EXPORT_VERSION)
712 || (manifest.version == LEGACY_EXPORT_VERSION
713 && (!manifest.attachments.is_empty() || !manifest.objects.is_empty()))
714 || manifest.tasks.is_empty()
715 || actions.len() != manifest.actions.len()
716 || task_identities.len() != manifest.tasks.len()
717 || manifest.tasks.iter().any(|task| !task.validate())
718 || manifest
719 .actions
720 .iter()
721 .any(|action| action.algorithm != "blake3" || action.validate().is_err())
722 || manifest.tasks.iter().any(|task| {
723 task.predictions
724 .iter()
725 .any(|prediction| !actions.contains(&prediction.action))
726 })
727 || manifest
728 .attachments
729 .keys()
730 .any(|name| !valid_attachment_name(name))
731 || manifest.attachments.values().any(|digest| {
732 digest.algorithm != "blake3"
733 || digest.validate().is_err()
734 || !manifest.objects.contains(digest)
735 })
736 || manifest.objects.iter().collect::<BTreeSet<_>>().len() != manifest.objects.len()
737 || manifest
738 .objects
739 .iter()
740 .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
741 {
742 eyre::bail!("unsupported or invalid cache export manifest");
743 }
744 let mut closure =
745 strict_closure(root, &actions).wrap_err("cache export is incomplete or corrupt")?;
746 let staged_cas = LocalCas::new(root);
747 for digest in &manifest.objects {
748 require_object(&staged_cas, &mut closure, digest)
749 .wrap_err("cache export attachment is incomplete or corrupt")?;
750 }
751 verify_pending(&mut closure.pending).wrap_err("cache export is incomplete or corrupt")?;
752
753 let results = closure
760 .results
761 .iter()
762 .map(|path| {
763 let result: RemoteActionResult = serde_json::from_slice(&std::fs::read(path)?)
764 .wrap_err_with(|| format!("action result is invalid: {}", path.display()))?;
765 Ok(result)
766 })
767 .collect::<Result<Vec<_>>>()
768 .wrap_err("cache export is incomplete or corrupt")?;
769
770 let cas = LocalCas::new(store);
771 for path in &closure.objects {
772 let relative = path.strip_prefix(root)?;
773 let source = root.join(relative);
774 let digest = addressed_digest(root, &source, false)
775 .ok_or_else(|| eyre::eyre!("invalid cache object path {}", relative.display()))?;
776 cas.adopt_verified_file(&digest, &source)?;
780 }
781 let action_cache = mbx_cache_core::LocalActionCache::new(store);
782 for result in &results {
783 action_cache.store(result)?;
784 }
785 for task in manifest.tasks {
786 merge_imported_manifest(store, task)?;
787 }
788 let bytes = match bundle_bytes {
789 Some(bytes) => {
790 std::fs::remove_dir_all(archive)
794 .wrap_err_with(|| format!("failed to remove {}", archive.display()))?;
795 bytes
796 }
797 None => std::fs::metadata(archive)?.len(),
798 };
799 Ok(ImportOutcome {
800 transfer: TransferOutcome {
801 actions: actions.len() as u64,
802 objects: closure.objects.len() as u64,
803 bytes,
804 },
805 attachments: manifest.attachments,
806 })
807}
808
809fn validate_export_additions(additions: &ExportAdditions) -> Result<()> {
810 if additions
811 .attachments
812 .keys()
813 .any(|name| !valid_attachment_name(name))
814 || additions
815 .attachments
816 .values()
817 .any(|digest| !additions.objects.contains(digest))
818 || additions
819 .objects
820 .iter()
821 .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
822 {
823 eyre::bail!("invalid cache export attachment");
824 }
825 Ok(())
826}
827
828fn valid_attachment_name(name: &str) -> bool {
829 !name.is_empty()
830 && name.len() <= 128
831 && name
832 .bytes()
833 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
834}
835
836#[derive(Default)]
838struct Closure {
839 objects: BTreeSet<PathBuf>,
840 results: BTreeSet<PathBuf>,
841 pending: Vec<PendingObject>,
842}
843
844struct PendingObject {
846 digest: CacheDigest,
847 path: PathBuf,
848}
849
850fn strict_closure(store: &Path, actions: &BTreeSet<CacheDigest>) -> Result<Closure> {
851 let cas = LocalCas::new(store);
852 let action_cache = mbx_cache_core::LocalActionCache::new(store);
853 let mut closure = Closure::default();
854 let mut directories = BTreeSet::new();
855 for action in actions {
856 let result = action_cache
857 .find(action)?
858 .ok_or_else(|| eyre::eyre!("action result is missing for {}", action.hash))?;
859 closure.results.insert(action_cache.path_for(action)?);
860 require_object(&cas, &mut closure, &result.action)?;
861 if let Some(metadata) = &result.metadata {
862 let path = require_parsed_object(&cas, &mut closure, metadata)?;
863 let captured: CapturedOutput = serde_json::from_slice(&std::fs::read(path)?)
864 .wrap_err("action metadata is invalid")?;
865 require_object(&cas, &mut closure, &captured.stdout)?;
866 require_object(&cas, &mut closure, &captured.stderr)?;
867 }
868 if let Some(root) = &result.output_root {
869 let mut nodes = vec![root.clone()];
870 while let Some(digest) = nodes.pop() {
871 if !directories.insert(digest.clone()) {
872 continue;
873 }
874 let path = require_parsed_object(&cas, &mut closure, &digest)?;
875 let directory: CacheDirectory = serde_json::from_slice(&std::fs::read(path)?)
876 .wrap_err("output directory is invalid")?;
877 for file in directory.files {
878 require_object(&cas, &mut closure, &file.digest)?;
879 }
880 for child in directory.directories {
881 nodes.push(child.digest);
882 }
883 }
884 }
885 }
886 Ok(closure)
887}
888
889fn verify_pending(pending: &mut [PendingObject]) -> Result<()> {
897 if pending.is_empty() {
898 return Ok(());
899 }
900 pending.sort_by(|left, right| {
904 right
905 .digest
906 .size
907 .cmp(&left.digest.size)
908 .then_with(|| left.path.cmp(&right.path))
909 });
910 let workers = std::thread::available_parallelism()
911 .map(usize::from)
912 .unwrap_or(1)
913 .min(pending.len());
914 let next = AtomicUsize::new(0);
915 let failure: Mutex<Option<(PathBuf, eyre::Report)>> = Mutex::new(None);
918 let pending: &[PendingObject] = pending;
919 std::thread::scope(|scope| {
920 for _ in 0..workers {
921 scope.spawn(|| {
922 loop {
923 let index = next.fetch_add(1, Ordering::Relaxed);
924 let Some(entry) = pending.get(index) else {
925 break;
926 };
927 let error = match entry.digest.matches_file(&entry.path) {
928 Ok(true) => continue,
929 Ok(false) => eyre::eyre!(
930 "local CAS blob failed digest verification: {}",
931 entry.path.display()
932 ),
933 Err(error) => {
934 error.wrap_err(format!("failed to verify {}", entry.path.display()))
935 }
936 };
937 let mut failure = failure.lock().unwrap_or_else(|error| error.into_inner());
938 if failure.as_ref().is_none_or(|(path, _)| entry.path < *path) {
939 *failure = Some((entry.path.clone(), error));
940 }
941 }
942 });
943 }
944 });
945 match failure
946 .into_inner()
947 .unwrap_or_else(|error| error.into_inner())
948 {
949 Some((_, error)) => Err(error),
950 None => Ok(()),
951 }
952}
953
954#[derive(Deserialize)]
955struct CapturedOutput {
956 stdout: CacheDigest,
957 stderr: CacheDigest,
958}
959
960fn merge_imported_manifest(destination: &Path, mut imported: TaskActionManifest) -> Result<()> {
961 let identity = imported.task.clone();
962 let destination_path = task_manifest_path(destination, &identity);
963 let lock_path = task_manifest_lock_path(destination, &identity);
964 std::fs::create_dir_all(lock_path.parent().expect("task manifest lock has a parent"))?;
965 let mut lock = fslock::LockFile::open(&lock_path)?;
966 lock.lock()?;
967 if let Ok(bytes) = std::fs::read(&destination_path)
968 && let Ok(existing) = serde_json::from_slice::<TaskActionManifest>(&bytes)
969 && existing.task == identity
970 && existing.validate()
971 {
972 let existing_predictions: BTreeMap<_, _> = existing
973 .predictions
974 .iter()
975 .cloned()
976 .map(|prediction| (prediction.invocation.clone(), prediction))
977 .collect();
978 let updates = imported
981 .predictions
982 .into_iter()
983 .map(|prediction| {
984 existing_predictions
985 .get(&prediction.invocation)
986 .cloned()
987 .unwrap_or(prediction)
988 })
989 .collect();
990 imported.predictions =
991 merge_task_action_predictions(existing.predictions, updates, &BTreeSet::new())?;
992 }
993 write_atomic(&destination_path, &serde_json::to_vec(&imported)?)
994}
995
996fn require_object(cas: &LocalCas, closure: &mut Closure, digest: &CacheDigest) -> Result<()> {
1003 let expected = cas.path_for(digest)?;
1004 if !closure.objects.insert(expected.clone()) {
1005 return Ok(());
1006 }
1007 let metadata = match std::fs::metadata(&expected) {
1008 Ok(metadata) => metadata,
1009 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1010 eyre::bail!("cache object is missing for {}", digest.hash)
1011 }
1012 Err(error) => {
1013 return Err(error).wrap_err_with(|| format!("failed to read {}", expected.display()));
1014 }
1015 };
1016 if metadata.len() != digest.size {
1017 eyre::bail!(
1018 "local CAS blob failed digest verification: {}",
1019 expected.display()
1020 );
1021 }
1022 closure.pending.push(PendingObject {
1023 digest: digest.clone(),
1024 path: expected,
1025 });
1026 Ok(())
1027}
1028
1029fn require_parsed_object(
1036 cas: &LocalCas,
1037 closure: &mut Closure,
1038 digest: &CacheDigest,
1039) -> Result<PathBuf> {
1040 let path = cas
1041 .find(digest)?
1042 .ok_or_else(|| eyre::eyre!("cache object is missing for {}", digest.hash))?;
1043 closure.objects.insert(path.clone());
1044 Ok(path)
1045}
1046
1047fn task_manifest_path(store: &Path, identity: &str) -> PathBuf {
1048 store
1049 .join("task-manifests/v1")
1050 .join(format!("{identity}.json"))
1051}
1052
1053fn task_manifest_lock_path(store: &Path, identity: &str) -> PathBuf {
1054 store
1055 .join("task-manifests/v1/locks")
1056 .join(format!("{identity}.lock"))
1057}
1058
1059fn append_file(builder: &mut tar::Builder<std::fs::File>, root: &Path, path: &Path) -> Result<()> {
1060 let name = path.strip_prefix(root)?;
1061 builder.append_path_with_name(path, name)?;
1062 Ok(())
1063}
1064
1065fn append_bytes(
1066 builder: &mut tar::Builder<std::fs::File>,
1067 name: &Path,
1068 bytes: &[u8],
1069) -> Result<()> {
1070 let mut header = tar::Header::new_gnu();
1071 header.set_size(bytes.len() as u64);
1072 header.set_mode(0o644);
1073 header.set_cksum();
1074 builder.append_data(&mut header, name, bytes)?;
1075 Ok(())
1076}
1077
1078fn unpack_archive(archive: &Path, staging: &Path) -> Result<()> {
1080 let file = std::fs::File::open(archive)
1081 .wrap_err_with(|| format!("failed to open {}", archive.display()))?;
1082 let mut bundle = tar::Archive::new(file);
1083 let mut seen = BTreeSet::new();
1084 for entry in bundle.entries()? {
1085 let mut entry = entry?;
1086 let entry_type = entry.header().entry_type();
1087 if !entry_type.is_file() && !entry_type.is_gnu_sparse() {
1088 eyre::bail!("cache export contains a non-file entry");
1089 }
1090 let path = entry.path()?.into_owned();
1091 validate_archive_path(&path)?;
1092 if !seen.insert(path.clone()) {
1093 eyre::bail!("cache export contains duplicate entry {}", path.display());
1094 }
1095 let destination = staging.join(&path);
1096 std::fs::create_dir_all(destination.parent().expect("entry has a parent"))?;
1097 entry.unpack(&destination)?;
1101 }
1102 Ok(())
1103}
1104
1105fn validate_directory_bundle(root: &Path) -> Result<()> {
1113 let mut pending = vec![root.to_path_buf()];
1114 while let Some(directory) = pending.pop() {
1115 for entry in std::fs::read_dir(&directory)
1116 .wrap_err_with(|| format!("failed to read {}", directory.display()))?
1117 {
1118 let entry = entry?;
1119 let metadata = entry.metadata()?;
1120 let path = entry.path();
1121 if metadata.is_dir() {
1122 pending.push(path);
1123 continue;
1124 }
1125 if !metadata.is_file() {
1126 eyre::bail!("cache export contains a non-file entry");
1127 }
1128 validate_archive_path(path.strip_prefix(root)?)?;
1129 reject_linked_file(&metadata, &path)?;
1130 }
1131 }
1132 Ok(())
1133}
1134
1135#[cfg(unix)]
1142fn reject_linked_file(metadata: &std::fs::Metadata, path: &Path) -> Result<()> {
1143 use std::os::unix::fs::MetadataExt as _;
1144
1145 if metadata.nlink() > 1 {
1146 eyre::bail!("cache export contains a hard link at {}", path.display());
1147 }
1148 Ok(())
1149}
1150
1151#[cfg(not(unix))]
1157fn reject_linked_file(_metadata: &std::fs::Metadata, _path: &Path) -> Result<()> {
1158 Ok(())
1159}
1160
1161fn validate_archive_path(path: &Path) -> Result<()> {
1162 if path.as_os_str().is_empty()
1163 || path.is_absolute()
1164 || path
1165 .components()
1166 .any(|component| !matches!(component, Component::Normal(_)))
1167 {
1168 eyre::bail!("cache export contains unsafe path {}", path.display());
1169 }
1170 let parts = path
1175 .components()
1176 .map(|component| component.as_os_str().to_string_lossy().into_owned())
1177 .collect::<Vec<_>>();
1178 let expected = match parts.as_slice() {
1179 [name] => name == EXPORT_MANIFEST,
1180 [root, version, _rest @ ..] => {
1181 parts.len() > 2 && (root == "cas" || root == "action-results") && version == "v1"
1182 }
1183 [] => false,
1184 };
1185 if !expected {
1186 eyre::bail!("cache export contains unexpected path {}", path.display());
1187 }
1188 Ok(())
1189}
1190
1191#[derive(Debug, Clone, Serialize, Deserialize)]
1198#[serde(deny_unknown_fields)]
1199struct CheckoutRecord {
1200 version: u8,
1201 workspace_root: PathBuf,
1202 target_dir: PathBuf,
1203 updated_secs: u64,
1204}
1205
1206pub fn stats(store: &Path) -> Result<StoreStats> {
1208 let objects = walk_files(&store.join(CAS_DIR))?;
1209 let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
1210 let checkouts = scan_checkouts(store)?;
1211 Ok(StoreStats {
1212 objects: objects.len() as u64,
1213 object_bytes: objects.iter().map(|entry| entry.size).sum(),
1214 action_results: results.len() as u64,
1215 action_result_bytes: results.iter().map(|entry| entry.size).sum(),
1216 live_checkouts: checkouts.live_records,
1217 stale_checkouts: checkouts.stale_records.len() as u64,
1218 })
1219}
1220
1221fn checkout_target_bytes(sizes: &mut BTreeMap<PathBuf, u64>, record: &CheckoutRecord) -> u64 {
1228 if record.target_dir == record.workspace_root {
1229 return 0;
1230 }
1231 cached_tree_bytes(sizes, &record.target_dir)
1232}
1233
1234pub fn projects(store: &Path) -> Result<Vec<ProjectUsage>> {
1240 project_usage(store, true)
1241}
1242
1243pub fn live_project_cache_bytes(store: &Path) -> Result<Vec<u64>> {
1249 Ok(project_usage(store, false)?
1250 .into_iter()
1251 .filter(|project| project.live)
1252 .map(|project| project.action_bytes)
1253 .collect())
1254}
1255
1256fn project_usage(store: &Path, size_targets: bool) -> Result<Vec<ProjectUsage>> {
1257 let mut projects: BTreeMap<PathBuf, (BTreeSet<String>, bool, u64)> = BTreeMap::new();
1258 let mut target_sizes = BTreeMap::new();
1259 let root = store.join(CHECKOUTS_DIR);
1260 for identity_entry in read_dir_or_empty(&root)? {
1261 let identity = identity_entry.file_name().to_string_lossy().into_owned();
1262 if !is_task_identity(&identity) || !identity_entry.file_type()?.is_dir() {
1263 continue;
1264 }
1265 for entry in walk_files(&identity_entry.path())? {
1266 let Some(record) = read_checkout_record(&entry.path) else {
1267 continue;
1268 };
1269 let project = projects.entry(record.workspace_root.clone()).or_default();
1270 if claim_is_live(store, &record) {
1271 project.0.insert(identity.clone());
1272 project.1 = true;
1273 }
1274 if size_targets {
1275 project.2 = project
1276 .2
1277 .max(checkout_target_bytes(&mut target_sizes, &record));
1278 }
1279 }
1280 }
1281 let live = projects
1282 .values()
1283 .flat_map(|(identities, _, _)| identities.iter().cloned())
1284 .collect::<BTreeSet<_>>();
1285 let mut reachability = Reachability::new(store);
1286 for_each_manifest(store, &live, |identity, actions| {
1287 reachability.record(identity, &actions);
1288 });
1289 let mut usages = Vec::new();
1290 for (workspace_root, (identities, live, target_bytes)) in projects {
1291 let action_bytes = reachability.bytes(&identities);
1292 usages.push(ProjectUsage {
1293 workspace_root,
1294 identities: identities.len() as u64,
1295 action_bytes,
1296 target_bytes,
1297 live,
1298 });
1299 }
1300 usages.sort_by(|left, right| {
1301 right
1302 .action_bytes
1303 .saturating_add(right.target_bytes)
1304 .cmp(&left.action_bytes.saturating_add(left.target_bytes))
1305 .then_with(|| left.workspace_root.cmp(&right.workspace_root))
1306 });
1307 Ok(usages)
1308}
1309
1310fn for_each_manifest(
1319 store: &Path,
1320 identities: &BTreeSet<String>,
1321 mut consume: impl FnMut(&str, Vec<CacheDigest>),
1322) {
1323 const MAX_WORKERS: usize = 4;
1324 let identities = identities.iter().collect::<Vec<_>>();
1325 let workers = std::thread::available_parallelism()
1326 .map(usize::from)
1327 .unwrap_or(1)
1328 .min(MAX_WORKERS)
1329 .min(identities.len());
1330 let next = AtomicUsize::new(0);
1331 let (sender, receiver) = std::sync::mpsc::sync_channel(workers);
1332 std::thread::scope(|scope| {
1333 for _ in 0..workers {
1334 let sender = sender.clone();
1335 let (identities, next) = (&identities, &next);
1336 scope.spawn(move || {
1337 while let Some(identity) = identities.get(next.fetch_add(1, Ordering::Relaxed)) {
1338 let actions = task_manifest_actions(store, identity).unwrap_or_else(|error| {
1342 log::debug!("could not read the manifest for {identity}: {error}");
1343 Vec::new()
1344 });
1345 if sender.send((*identity, actions)).is_err() {
1346 break;
1347 }
1348 }
1349 });
1350 }
1351 drop(sender);
1352 for (identity, actions) in receiver {
1353 consume(identity, actions);
1354 }
1355 });
1356}
1357
1358struct Reachability {
1371 store: PathBuf,
1372 cas: LocalCas,
1373 action_cache: mbx_cache_core::LocalActionCache,
1374 indices: HashMap<PathBuf, usize>,
1375 sizes: Vec<u64>,
1377 identities: HashMap<String, Rc<[Rc<[usize]>]>>,
1378 actions: HashMap<CacheDigest, Rc<[usize]>>,
1379 directories: HashMap<CacheDigest, Option<Rc<[CacheDigest]>>>,
1380 directory_paths: HashMap<CacheDigest, Rc<[usize]>>,
1382 counted: Vec<usize>,
1384 workspaces: usize,
1385}
1386
1387impl Reachability {
1388 fn new(store: &Path) -> Self {
1389 Self {
1390 store: store.to_path_buf(),
1391 cas: LocalCas::new(store),
1392 action_cache: mbx_cache_core::LocalActionCache::new(store),
1393 indices: HashMap::new(),
1394 sizes: Vec::new(),
1395 identities: HashMap::new(),
1396 actions: HashMap::new(),
1397 directories: HashMap::new(),
1398 directory_paths: HashMap::new(),
1399 counted: Vec::new(),
1400 workspaces: 0,
1401 }
1402 }
1403
1404 fn bytes(&mut self, identities: &BTreeSet<String>) -> u64 {
1408 let reachable = identities
1409 .iter()
1410 .filter_map(|identity| self.identities.get(identity).cloned())
1411 .collect::<Vec<_>>();
1412 self.workspaces += 1;
1413 self.counted.resize(self.sizes.len(), 0);
1414 let mut bytes = 0_u64;
1415 for index in reachable
1416 .iter()
1417 .flat_map(|actions| actions.iter())
1418 .flat_map(|paths| paths.iter())
1419 {
1420 if self.counted[*index] != self.workspaces {
1421 self.counted[*index] = self.workspaces;
1422 bytes = bytes.saturating_add(self.sizes[*index]);
1423 }
1424 }
1425 bytes
1426 }
1427
1428 fn record(&mut self, identity: &str, actions: &[CacheDigest]) {
1430 let actions = actions
1431 .iter()
1432 .map(|action| self.action(action))
1433 .collect::<Rc<[_]>>();
1434 self.identities.insert(identity.to_string(), actions);
1435 }
1436
1437 fn action(&mut self, action: &CacheDigest) -> Rc<[usize]> {
1438 if let Some(paths) = self.actions.get(action) {
1439 return paths.clone();
1440 }
1441 let mut paths = Vec::new();
1442 if let Ok(path) = self.action_cache.path_for(action) {
1443 paths.push(self.index(path));
1444 }
1445 if let Some(result) = read_action_result(&self.store, action) {
1446 self.push_digest(&mut paths, &result.action);
1447 if let Some(metadata) = &result.metadata {
1448 self.push_digest(&mut paths, metadata);
1449 if let Some(rustc) = read_rustc_metadata(&self.cas, metadata) {
1450 self.push_digest(&mut paths, &rustc.stdout);
1451 self.push_digest(&mut paths, &rustc.stderr);
1452 }
1453 }
1454 if let Some(output_root) = &result.output_root {
1455 self.push_digest(&mut paths, output_root);
1456 let mut pending = vec![output_root.clone()];
1457 let mut visited = HashSet::new();
1458 while let Some(digest) = pending.pop() {
1459 if !visited.insert(digest.clone()) {
1460 continue;
1461 }
1462 let Some(children) = self.directory(&digest) else {
1463 continue;
1464 };
1465 paths.extend(self.directory_paths[&digest].iter().copied());
1466 pending.extend(children.iter().cloned());
1467 }
1468 }
1469 }
1470 paths.sort_unstable();
1471 paths.dedup();
1472 let paths = Rc::<[usize]>::from(paths);
1473 self.actions.insert(action.clone(), paths.clone());
1474 paths
1475 }
1476
1477 fn directory(&mut self, digest: &CacheDigest) -> Option<Rc<[CacheDigest]>> {
1479 if let Some(children) = self.directories.get(digest) {
1480 return children.clone();
1481 }
1482 let children = read_directory(&self.cas, digest).map(|directory| {
1483 let mut paths = Vec::new();
1484 for file in &directory.files {
1485 self.push_digest(&mut paths, &file.digest);
1486 }
1487 for child in &directory.directories {
1488 self.push_digest(&mut paths, &child.digest);
1489 }
1490 self.directory_paths.insert(digest.clone(), Rc::from(paths));
1491 directory
1492 .directories
1493 .into_iter()
1494 .map(|child| child.digest)
1495 .collect::<Rc<[_]>>()
1496 });
1497 self.directories.insert(digest.clone(), children.clone());
1498 children
1499 }
1500
1501 fn push_digest(&mut self, paths: &mut Vec<usize>, digest: &CacheDigest) {
1502 if let Ok(path) = self.cas.path_for(digest) {
1503 paths.push(self.index(path));
1504 }
1505 }
1506
1507 fn index(&mut self, path: PathBuf) -> usize {
1508 if let Some(index) = self.indices.get(&path) {
1509 return *index;
1510 }
1511 let size = std::fs::metadata(&path).map_or(0, |metadata| metadata.len());
1512 let index = self.sizes.len();
1513 self.sizes.push(size);
1514 self.indices.insert(path, index);
1515 index
1516 }
1517}
1518
1519pub fn largest(store: &Path, limit: usize) -> Result<Vec<LargestEntry>> {
1521 let mut entries = Vec::new();
1522 for (kind, root, action_result) in [
1523 ("object", store.join(CAS_DIR), false),
1524 ("action result", store.join(ACTION_RESULTS_DIR), true),
1525 ] {
1526 entries.extend(
1527 walk_files(&root)?
1528 .into_iter()
1529 .filter(|entry| addressed_digest(store, &entry.path, action_result).is_some())
1530 .map(|entry| LargestEntry {
1531 kind,
1532 path: entry.path,
1533 bytes: entry.size,
1534 }),
1535 );
1536 }
1537 entries.sort_by(|left, right| {
1538 right
1539 .bytes
1540 .cmp(&left.bytes)
1541 .then_with(|| left.path.cmp(&right.path))
1542 });
1543 entries.truncate(limit);
1544 Ok(entries)
1545}
1546
1547pub fn verify(store: &Path) -> Result<VerifyOutcome> {
1549 let cas = LocalCas::new(store);
1550 let action_cache = mbx_cache_core::LocalActionCache::new(store);
1551 let virtually_removed = HashSet::new();
1554 let mut outcome = VerifyOutcome::default();
1555 for entry in walk_files(&store.join(CAS_DIR))? {
1556 let Some(digest) = addressed_digest(store, &entry.path, false) else {
1557 continue;
1558 };
1559 outcome.checked_objects += 1;
1560 if !matches!(cas.find(&digest), Ok(Some(_))) {
1561 outcome.problems.push(entry.path);
1562 }
1563 }
1564 for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
1565 let Some(digest) = addressed_digest(store, &entry.path, true) else {
1566 continue;
1567 };
1568 outcome.checked_action_results += 1;
1569 if !matches!(action_cache.find(&digest), Ok(Some(_)))
1570 || action_result_is_dangling(&cas, &entry.path, &virtually_removed)?
1571 {
1572 outcome.problems.push(entry.path);
1573 }
1574 }
1575 outcome.problems.sort();
1576 Ok(outcome)
1577}
1578
1579pub fn remove_project(store: &Path, workspace_root: &Path) -> Result<RemoveProjectOutcome> {
1581 let mut outcome = RemoveProjectOutcome::default();
1582 for entry in walk_files(&store.join(CHECKOUTS_DIR))? {
1583 if read_checkout_record(&entry.path)
1584 .is_some_and(|record| record.workspace_root == workspace_root)
1585 {
1586 std::fs::remove_file(&entry.path)?;
1587 outcome.removed_checkout_records += 1;
1588 if let Some(parent) = entry.path.parent() {
1589 let _ = std::fs::remove_dir(parent);
1590 }
1591 }
1592 }
1593 match std::fs::remove_file(latest_receipt_path(store, workspace_root)) {
1594 Ok(()) => {}
1595 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1596 Err(error) => return Err(error.into()),
1597 }
1598 Ok(outcome)
1599}
1600
1601pub fn record_checkout(
1613 store: &Path,
1614 identity: &str,
1615 workspace_root: &Path,
1616 target_dir: &Path,
1617) -> Result<()> {
1618 let record = CheckoutRecord {
1619 version: CHECKOUT_RECORD_VERSION,
1620 workspace_root: workspace_root.to_path_buf(),
1621 target_dir: target_dir.to_path_buf(),
1622 updated_secs: SystemTime::now()
1623 .duration_since(UNIX_EPOCH)
1624 .map(|since| since.as_secs())
1625 .unwrap_or_default(),
1626 };
1627 let mut contents = serde_json::to_vec(&record)?;
1628 contents.push(b'\n');
1629 write_advisory(
1632 &checkout_record_path(store, identity, workspace_root),
1633 &contents,
1634 )
1635}
1636
1637fn checkout_record_path(store: &Path, identity: &str, workspace_root: &Path) -> PathBuf {
1638 let key = CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash;
1642 store
1643 .join(CHECKOUTS_DIR)
1644 .join(identity)
1645 .join(format!("{key}.json"))
1646}
1647
1648pub fn gc(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
1658 gc_with_mode(store, max_bytes, false)
1659}
1660
1661pub fn gc_dry_run(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
1663 gc_with_mode(store, max_bytes, true)
1664}
1665
1666fn gc_with_mode(store: &Path, max_bytes: u64, dry_run: bool) -> Result<GcOutcome> {
1667 let mut objects = walk_files(&store.join(CAS_DIR))?;
1668 let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
1669 let mut live_bytes = objects
1670 .iter()
1671 .chain(results.iter())
1672 .map(|entry| entry.size)
1673 .sum::<u64>();
1674
1675 let mut outcome = GcOutcome::default();
1676
1677 let checkouts = scan_checkouts(store)?;
1680 for path in &checkouts.stale_records {
1681 if dry_run || matches!(remove(path)?, Removal::Removed) {
1682 outcome.removed_checkout_records += 1;
1683 }
1684 if !dry_run && let Some(parent) = path.parent() {
1687 let _ = std::fs::remove_dir(parent);
1688 }
1689 }
1690
1691 let mut virtually_removed = HashSet::new();
1692 if live_bytes > max_bytes {
1693 let mut rooted = rooted_objects(store, &checkouts.live_identities)?;
1697 rooted.extend(rooted_action_objects(
1704 store,
1705 grouped_receipt_actions(store)?,
1706 ));
1707 objects.sort_by_cached_key(|entry| (rooted.contains(&entry.path), entry.used));
1711 let evicted = evict_objects(&objects, &rooted, live_bytes, max_bytes, |path| {
1712 if dry_run {
1713 virtually_removed.insert(path.to_path_buf());
1714 Ok(Removal::Removed)
1715 } else {
1716 remove(path)
1717 }
1718 })?;
1719 live_bytes = evicted.remaining_bytes;
1720 outcome.removed_objects += evicted.removed_objects;
1721 outcome.removed_bytes += evicted.removed_bytes;
1722 }
1723
1724 let cas = LocalCas::new(store);
1729 for entry in &results {
1730 if action_result_is_dangling(&cas, &entry.path, &virtually_removed)? {
1731 match if dry_run {
1732 Removal::Removed
1733 } else {
1734 remove(&entry.path)?
1735 } {
1736 Removal::Removed => {
1737 live_bytes = live_bytes.saturating_sub(entry.size);
1738 outcome.removed_action_results += 1;
1739 outcome.removed_bytes += entry.size;
1740 }
1741 Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
1742 Removal::Blocked => {}
1743 }
1744 }
1745 }
1746
1747 let sessions = prune_sessions(store, dry_run)?;
1748 outcome.removed_session_streams += sessions.removed_streams;
1749 outcome.removed_bytes += sessions.removed_bytes;
1750 prune_import_staging(store, dry_run);
1751
1752 outcome.remaining_bytes = live_bytes;
1753 Ok(outcome)
1754}
1755
1756fn prune_import_staging(store: &Path, dry_run: bool) {
1768 if dry_run {
1769 return;
1770 }
1771 let root = store.join(IMPORT_STAGING_DIR);
1772 let Ok(entries) = read_dir_or_empty(&root) else {
1773 return;
1774 };
1775 for entry in entries {
1776 let Ok(metadata) = entry.metadata() else {
1777 continue;
1778 };
1779 let abandoned = metadata
1780 .modified()
1781 .ok()
1782 .and_then(|modified| SystemTime::now().duration_since(modified).ok())
1783 .is_some_and(|age| age >= IMPORT_STAGING_RETENTION);
1784 if !abandoned {
1785 continue;
1786 }
1787 let path = entry.path();
1788 if !metadata.is_dir() {
1789 if path
1792 .extension()
1793 .is_some_and(|extension| extension == "lock")
1794 && !claimed_staging_path(&path).exists()
1795 {
1796 let _ = std::fs::remove_file(&path);
1797 }
1798 continue;
1799 }
1800 let lock_path = import_staging_lock_path(&path);
1806 let Ok(mut lock) = fslock::LockFile::open(&lock_path) else {
1807 continue;
1808 };
1809 if !matches!(lock.try_lock(), Ok(true)) {
1810 continue;
1811 }
1812 if let Err(error) = std::fs::remove_dir_all(&path) {
1813 log::debug!(
1814 "could not remove abandoned import staging {}: {error}",
1815 path.display()
1816 );
1817 continue;
1818 }
1819 drop(lock);
1820 let _ = std::fs::remove_file(&lock_path);
1821 }
1822}
1823
1824fn claimed_staging_path(lock: &Path) -> PathBuf {
1826 lock.with_extension("")
1827}
1828
1829fn import_staging_lock_path(staging: &Path) -> PathBuf {
1834 let mut name = staging.as_os_str().to_os_string();
1835 name.push(".lock");
1836 PathBuf::from(name)
1837}
1838
1839struct StagingClaim {
1845 lock: Option<fslock::LockFile>,
1846 path: PathBuf,
1847}
1848
1849impl Drop for StagingClaim {
1850 fn drop(&mut self) {
1851 drop(self.lock.take());
1854 let _ = std::fs::remove_file(&self.path);
1855 }
1856}
1857
1858fn lock_import_staging(staging: &Path) -> Result<StagingClaim> {
1860 let path = import_staging_lock_path(staging);
1861 let mut lock = fslock::LockFile::open(&path)
1862 .wrap_err_with(|| format!("failed to open {}", path.display()))?;
1863 lock.lock()?;
1864 Ok(StagingClaim {
1865 lock: Some(lock),
1866 path,
1867 })
1868}
1869
1870#[derive(Debug, Default, PartialEq, Eq)]
1871struct SessionPrune {
1872 removed_streams: u64,
1873 removed_bytes: u64,
1874}
1875
1876fn prune_sessions(store: &Path, dry_run: bool) -> Result<SessionPrune> {
1877 let mut prune = SessionPrune::default();
1878 let ids = events::session_ids(store);
1879 let surplus = ids.len().saturating_sub(MAX_SESSIONS);
1880 for (index, id) in ids.iter().enumerate() {
1881 let paths = events::session_paths(store, id);
1882 let metadata = match std::fs::metadata(&paths.events) {
1883 Ok(metadata) => metadata,
1884 Err(_) => continue,
1885 };
1886 let stale = metadata
1887 .modified()
1888 .ok()
1889 .and_then(|at| SystemTime::now().duration_since(at).ok())
1890 .is_some_and(|age| age > SESSION_RETENTION);
1891 if index >= surplus && !stale {
1892 continue;
1893 }
1894 if events::session_is_live(store, id) {
1895 continue;
1896 }
1897 if dry_run {
1898 prune.removed_streams += 1;
1899 prune.removed_bytes += metadata.len();
1900 continue;
1901 }
1902 if matches!(remove(&paths.events)?, Removal::Removed) {
1903 prune.removed_streams += 1;
1904 prune.removed_bytes += metadata.len();
1905 let _ = std::fs::remove_file(&paths.lock);
1906 }
1907 }
1908 if !dry_run {
1909 for lock in events::orphaned_locks(store) {
1910 let _ = std::fs::remove_file(lock);
1911 }
1912 }
1913 Ok(prune)
1914}
1915
1916pub fn sweep_if_due(store: &Path, max_bytes: u64, interval: Duration) -> Result<Option<GcOutcome>> {
1922 if !claim_sweep(store, interval)? {
1923 return Ok(None);
1924 }
1925 gc(store, max_bytes).map(Some)
1926}
1927
1928pub fn claim_sweep(store: &Path, interval: Duration) -> Result<bool> {
1930 let lock_path = store.join(SWEEP_LOCK);
1931 std::fs::create_dir_all(lock_path.parent().expect("sweep lock has a parent"))?;
1932 let mut lock = fslock::LockFile::open(&lock_path)?;
1933 lock.lock()?;
1934
1935 let stamp = store.join(SWEEP_STAMP);
1936 if sweep_stamp_is_fresh(&stamp, interval) {
1937 return Ok(false);
1938 }
1939 write_atomic(&stamp, b"")?;
1940 Ok(true)
1941}
1942
1943pub fn sweep_is_due(store: &Path, interval: Duration) -> bool {
1951 !sweep_stamp_is_fresh(&store.join(SWEEP_STAMP), interval)
1952}
1953
1954fn sweep_stamp_is_fresh(stamp: &Path, interval: Duration) -> bool {
1955 std::fs::metadata(stamp)
1956 .and_then(|metadata| metadata.modified())
1957 .ok()
1958 .and_then(|modified| modified.elapsed().ok())
1959 .is_some_and(|since| since < interval)
1960}
1961
1962struct CheckoutScan {
1964 live_identities: BTreeSet<String>,
1965 live_records: u64,
1966 stale_records: Vec<PathBuf>,
1967}
1968
1969fn scan_checkouts(store: &Path) -> Result<CheckoutScan> {
1970 let root = store.join(CHECKOUTS_DIR);
1971 let mut scan = CheckoutScan {
1972 live_identities: BTreeSet::new(),
1973 live_records: 0,
1974 stale_records: Vec::new(),
1975 };
1976 let listing = match std::fs::read_dir(&root) {
1977 Ok(listing) => listing,
1978 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(scan),
1979 Err(error) => {
1980 return Err(error).wrap_err_with(|| format!("failed to read {}", root.display()));
1981 }
1982 };
1983 for entry in listing {
1984 let entry = entry?;
1985 let name = entry.file_name().to_string_lossy().into_owned();
1986 if !is_task_identity(&name) || !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
1992 continue;
1993 }
1994 for record in walk_files(&entry.path())? {
1995 match read_checkout_record(&record.path) {
1996 Some(checkout) if claim_is_live(store, &checkout) => {
1997 scan.live_identities.insert(name.clone());
1998 scan.live_records += 1;
1999 }
2000 Some(_) => scan.stale_records.push(record.path),
2001 None => {}
2004 }
2005 }
2006 }
2007 Ok(scan)
2008}
2009
2010fn read_checkout_record(path: &Path) -> Option<CheckoutRecord> {
2011 let bytes = std::fs::read(path).ok()?;
2012 let record = serde_json::from_slice::<CheckoutRecord>(&bytes).ok()?;
2013 (record.version == CHECKOUT_RECORD_VERSION).then_some(record)
2014}
2015
2016fn grouped_receipt_actions(store: &Path) -> Result<BTreeSet<CacheDigest>> {
2018 let root = store.join(BUILD_RECEIPTS_DIR).join("groups");
2019 Ok(walk_files(&root)?
2020 .into_iter()
2021 .filter_map(|entry| read_build_receipt(&entry.path))
2022 .flat_map(|receipt| receipt.predictions.into_iter())
2023 .map(|prediction| prediction.action)
2024 .collect())
2025}
2026
2027fn claim_is_live(store: &Path, record: &CheckoutRecord) -> bool {
2030 checkout_is_live_on(store, &record.workspace_root) && !claim_has_expired(record)
2031}
2032
2033fn claim_has_expired(record: &CheckoutRecord) -> bool {
2034 let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
2035 return false;
2036 };
2037 now.as_secs()
2038 .saturating_sub(record.updated_secs)
2039 .gt(&CHECKOUT_RETENTION.as_secs())
2040}
2041
2042pub fn checkout_is_live_on(store: &Path, workspace_root: &Path) -> bool {
2055 if !matches!(workspace_root.try_exists(), Ok(false)) {
2056 return true;
2057 }
2058
2059 let Ok(store_metadata) = std::fs::metadata(store) else {
2060 return true;
2061 };
2062 for ancestor in workspace_root.ancestors().skip(1) {
2063 match std::fs::metadata(ancestor) {
2064 Ok(ancestor_metadata) => {
2065 return !same_filesystem(store, &store_metadata, ancestor, &ancestor_metadata);
2066 }
2067 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2068 Err(_) => return true,
2069 }
2070 }
2071 true
2072}
2073
2074pub fn checkout_is_live(workspace_root: &Path) -> bool {
2080 let Some(parent) = workspace_root.parent() else {
2081 return true;
2082 };
2083 checkout_is_live_on(parent, workspace_root)
2084}
2085
2086pub fn same_filesystem_paths(a: &Path, b: &Path) -> bool {
2092 let (Ok(a_metadata), Ok(b_metadata)) = (std::fs::metadata(a), std::fs::metadata(b)) else {
2093 return false;
2094 };
2095 same_filesystem(a, &a_metadata, b, &b_metadata)
2096}
2097
2098#[cfg(unix)]
2099fn same_filesystem(
2100 _a_path: &Path,
2101 a: &std::fs::Metadata,
2102 _b_path: &Path,
2103 b: &std::fs::Metadata,
2104) -> bool {
2105 use std::os::unix::fs::MetadataExt as _;
2106 a.dev() == b.dev()
2107}
2108
2109#[cfg(windows)]
2110fn same_filesystem(
2111 a_path: &Path,
2112 _a: &std::fs::Metadata,
2113 b_path: &Path,
2114 _b: &std::fs::Metadata,
2115) -> bool {
2116 windows_volume_name(a_path).is_some_and(|volume| windows_volume_name(b_path) == Some(volume))
2117}
2118
2119#[cfg(windows)]
2120fn windows_volume_name(path: &Path) -> Option<Vec<u16>> {
2121 use std::os::windows::ffi::OsStrExt as _;
2122 use windows_sys::Win32::Storage::FileSystem::{
2123 GetVolumeNameForVolumeMountPointW, GetVolumePathNameW,
2124 };
2125
2126 let path = path
2127 .as_os_str()
2128 .encode_wide()
2129 .chain(Some(0))
2130 .collect::<Vec<_>>();
2131 let mut mount = vec![0_u16; 261];
2135 if unsafe { GetVolumePathNameW(path.as_ptr(), mount.as_mut_ptr(), mount.len() as u32) } == 0 {
2138 return None;
2139 }
2140 let mut volume = vec![0_u16; 261];
2141 if unsafe {
2144 GetVolumeNameForVolumeMountPointW(mount.as_ptr(), volume.as_mut_ptr(), volume.len() as u32)
2145 } == 0
2146 {
2147 return None;
2148 }
2149 volume.truncate(volume.iter().position(|unit| *unit == 0)?);
2150 Some(volume)
2151}
2152
2153#[cfg(not(any(unix, windows)))]
2154fn same_filesystem(
2155 _a_path: &Path,
2156 _a: &std::fs::Metadata,
2157 _b_path: &Path,
2158 _b: &std::fs::Metadata,
2159) -> bool {
2160 false
2161}
2162
2163fn rooted_objects(store: &Path, identities: &BTreeSet<String>) -> Result<HashSet<PathBuf>> {
2171 let mut actions = BTreeSet::new();
2172 for identity in identities {
2173 let manifest_actions = match task_manifest_actions(store, identity) {
2176 Ok(actions) => actions,
2177 Err(error) => {
2178 log::debug!("could not read the manifest for {identity}: {error}");
2179 continue;
2180 }
2181 };
2182 actions.extend(manifest_actions);
2183 }
2184 Ok(rooted_action_objects(store, actions))
2185}
2186
2187fn rooted_action_objects(
2189 store: &Path,
2190 actions: impl IntoIterator<Item = CacheDigest>,
2191) -> HashSet<PathBuf> {
2192 let cas = LocalCas::new(store);
2193 let mut rooted = HashSet::new();
2194 let mut visited = BTreeSet::new();
2195 for action in actions {
2196 let Some(result) = read_action_result(store, &action) else {
2197 continue;
2198 };
2199 root_digest(&cas, &mut rooted, &result.action);
2200 if let Some(metadata) = &result.metadata {
2201 root_digest(&cas, &mut rooted, metadata);
2202 if let Some(rustc) = read_rustc_metadata(&cas, metadata) {
2209 root_digest(&cas, &mut rooted, &rustc.stdout);
2210 root_digest(&cas, &mut rooted, &rustc.stderr);
2211 }
2212 }
2213 if let Some(output_root) = &result.output_root {
2214 root_digest(&cas, &mut rooted, output_root);
2215 root_tree(&cas, output_root, &mut rooted, &mut visited);
2216 }
2217 }
2218 rooted
2219}
2220
2221fn read_action_result(store: &Path, action: &CacheDigest) -> Option<RemoteActionResult> {
2222 let path = mbx_cache_core::LocalActionCache::new(store)
2223 .path_for(action)
2224 .ok()?;
2225 let bytes = std::fs::read(path).ok()?;
2226 serde_json::from_slice(&bytes).ok()
2227}
2228
2229fn root_tree(
2230 cas: &LocalCas,
2231 output_root: &CacheDigest,
2232 rooted: &mut HashSet<PathBuf>,
2233 visited: &mut BTreeSet<CacheDigest>,
2234) {
2235 let mut pending = vec![output_root.clone()];
2236 while let Some(digest) = pending.pop() {
2237 if !visited.insert(digest.clone()) {
2238 continue;
2239 }
2240 let Some(directory) = read_directory(cas, &digest) else {
2241 continue;
2242 };
2243 for file in &directory.files {
2244 root_digest(cas, rooted, &file.digest);
2245 }
2246 for child in &directory.directories {
2247 root_digest(cas, rooted, &child.digest);
2248 pending.push(child.digest.clone());
2249 }
2250 }
2251}
2252
2253fn read_rustc_metadata(cas: &LocalCas, digest: &CacheDigest) -> Option<RustcMetadata> {
2254 let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2255 serde_json::from_slice(&bytes).ok()
2256}
2257
2258fn read_directory(cas: &LocalCas, digest: &CacheDigest) -> Option<CacheDirectory> {
2259 let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2260 serde_json::from_slice(&bytes).ok()
2261}
2262
2263fn root_digest(cas: &LocalCas, rooted: &mut HashSet<PathBuf>, digest: &CacheDigest) {
2264 if let Ok(path) = cas.path_for(digest) {
2265 rooted.insert(path);
2266 }
2267}
2268
2269#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2270enum Removal {
2271 Removed,
2272 Missing,
2273 Blocked,
2274}
2275
2276fn remove(path: &Path) -> Result<Removal> {
2277 match std::fs::remove_file(path) {
2278 Ok(()) => Ok(Removal::Removed),
2279 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Removal::Missing),
2281 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2286 log::debug!("could not evict {}: {error}", path.display());
2287 Ok(Removal::Blocked)
2288 }
2289 Err(error) => Err(error).wrap_err_with(|| format!("failed to evict {}", path.display())),
2290 }
2291}
2292
2293#[derive(Debug, Default, PartialEq, Eq)]
2294struct ObjectEvictions {
2295 removed_objects: u64,
2296 removed_bytes: u64,
2297 remaining_bytes: u64,
2298}
2299
2300fn evict_objects(
2303 objects: &[Entry],
2304 rooted: &HashSet<PathBuf>,
2305 mut live_bytes: u64,
2306 max_bytes: u64,
2307 mut remove_file: impl FnMut(&Path) -> Result<Removal>,
2308) -> Result<ObjectEvictions> {
2309 let mut outcome = ObjectEvictions::default();
2310 let mut blocked_unrooted = false;
2311 for entry in objects {
2312 if live_bytes <= max_bytes {
2313 break;
2314 }
2315 let is_rooted = rooted.contains(&entry.path);
2316 if is_rooted && blocked_unrooted {
2317 break;
2321 }
2322 match remove_file(&entry.path)? {
2323 Removal::Removed => {
2324 live_bytes = live_bytes.saturating_sub(entry.size);
2325 outcome.removed_objects += 1;
2326 outcome.removed_bytes += entry.size;
2327 }
2328 Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
2329 Removal::Blocked if !is_rooted => blocked_unrooted = true,
2330 Removal::Blocked => {}
2331 }
2332 }
2333 outcome.remaining_bytes = live_bytes;
2334 Ok(outcome)
2335}
2336
2337fn action_result_is_dangling(
2355 cas: &LocalCas,
2356 path: &Path,
2357 virtually_removed: &HashSet<PathBuf>,
2358) -> Result<bool> {
2359 let bytes = match std::fs::read(path) {
2360 Ok(bytes) => bytes,
2361 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
2362 Err(error) => {
2363 return Err(error).wrap_err_with(|| format!("failed to read {}", path.display()));
2364 }
2365 };
2366 let Ok(result) = serde_json::from_slice::<RemoteActionResult>(&bytes) else {
2368 return Ok(false);
2369 };
2370 for digest in [
2371 Some(&result.action),
2372 result.metadata.as_ref(),
2373 result.output_root.as_ref(),
2374 ]
2375 .into_iter()
2376 .flatten()
2377 {
2378 match cas.path_for(digest) {
2379 Ok(path) => {
2380 if virtually_removed.contains(&path) || !path.exists() {
2381 return Ok(true);
2382 }
2383 }
2384 Err(_) => return Ok(true),
2386 }
2387 }
2388 Ok(false)
2389}
2390
2391struct Entry {
2392 path: PathBuf,
2393 size: u64,
2394 used: SystemTime,
2395}
2396
2397fn walk_files(root: &Path) -> Result<Vec<Entry>> {
2398 let mut entries = Vec::new();
2399 let mut pending = vec![root.to_path_buf()];
2400 while let Some(directory) = pending.pop() {
2401 let listing = match std::fs::read_dir(&directory) {
2402 Ok(listing) => listing,
2403 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2404 Err(error) => {
2405 return Err(error)
2406 .wrap_err_with(|| format!("failed to read {}", directory.display()));
2407 }
2408 };
2409 for entry in listing {
2410 let entry = entry?;
2411 let metadata = match entry.metadata() {
2412 Ok(metadata) => metadata,
2413 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2415 Err(error) => return Err(error.into()),
2416 };
2417 if metadata.is_dir() {
2418 pending.push(entry.path());
2419 } else if metadata.is_file() {
2420 let used = metadata
2421 .accessed()
2422 .or_else(|_| metadata.modified())
2423 .unwrap_or(SystemTime::UNIX_EPOCH);
2424 entries.push(Entry {
2425 path: entry.path(),
2426 size: metadata.len(),
2427 used,
2428 });
2429 }
2430 }
2431 }
2432 Ok(entries)
2433}
2434
2435fn read_dir_or_empty(root: &Path) -> Result<Vec<std::fs::DirEntry>> {
2436 match std::fs::read_dir(root) {
2437 Ok(entries) => entries
2438 .collect::<std::io::Result<Vec<_>>>()
2439 .map_err(Into::into),
2440 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
2441 Err(error) => Err(error).wrap_err_with(|| format!("failed to read {}", root.display())),
2442 }
2443}
2444
2445fn digest_from_path(path: &Path, action_result: bool) -> Option<CacheDigest> {
2446 let name = path.file_name()?.to_str()?;
2447 let name = if action_result {
2448 name.strip_suffix(".json")?
2449 } else {
2450 name
2451 };
2452 let (hash, size) = name.rsplit_once('-')?;
2453 let algorithm = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
2454 let digest = CacheDigest {
2455 algorithm,
2456 hash: hash.to_string(),
2457 size: size.parse().ok()?,
2458 };
2459 digest.validate().ok()?;
2460 Some(digest)
2461}
2462
2463fn addressed_digest(store: &Path, path: &Path, action_result: bool) -> Option<CacheDigest> {
2464 let digest = digest_from_path(path, action_result)?;
2465 let canonical = if action_result {
2466 mbx_cache_core::LocalActionCache::new(store)
2467 .path_for(&digest)
2468 .ok()?
2469 } else {
2470 LocalCas::new(store).path_for(&digest).ok()?
2471 };
2472 (canonical == path).then_some(digest)
2473}
2474
2475fn cached_tree_bytes(cache: &mut BTreeMap<PathBuf, u64>, root: &Path) -> u64 {
2476 *cache
2477 .entry(root.to_path_buf())
2478 .or_insert_with(|| tree_bytes(root))
2479}
2480
2481fn tree_bytes(root: &Path) -> u64 {
2482 let Ok(root_metadata) = std::fs::metadata(root) else {
2483 return 0;
2484 };
2485 if root_metadata.is_file() {
2486 return root_metadata.len();
2487 }
2488 let Ok(entries) = std::fs::read_dir(root) else {
2489 return 0;
2490 };
2491 let mut bytes = 0_u64;
2492 let mut pending = entries
2493 .filter_map(std::result::Result::ok)
2494 .map(|entry| entry.path())
2495 .collect::<Vec<_>>();
2496 while let Some(path) = pending.pop() {
2497 let Ok(metadata) = std::fs::symlink_metadata(&path) else {
2498 continue;
2499 };
2500 if metadata.is_file() {
2501 bytes = bytes.saturating_add(metadata.len());
2502 } else if metadata.is_dir()
2503 && let Ok(entries) = std::fs::read_dir(path)
2504 {
2505 pending.extend(
2506 entries
2507 .filter_map(std::result::Result::ok)
2508 .map(|entry| entry.path()),
2509 );
2510 }
2511 }
2512 bytes
2513}
2514
2515#[cfg(test)]
2516#[path = "store_tests.rs"]
2517mod tests;
2518
2519fn write_advisory(path: &Path, contents: &[u8]) -> Result<()> {
2522 let parent = path
2523 .parent()
2524 .filter(|parent| !parent.as_os_str().is_empty())
2525 .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
2526 std::fs::create_dir_all(parent)
2527 .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
2528 let mut temporary = tempfile::Builder::new()
2529 .prefix(".mbx-")
2530 .tempfile_in(parent)?;
2531 use std::io::Write as _;
2532 temporary.write_all(contents)?;
2533 temporary
2534 .persist(path)
2535 .map_err(|error| error.error)
2536 .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
2537 Ok(())
2538}
2539
2540fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
2541 let parent = path
2542 .parent()
2543 .filter(|parent| !parent.as_os_str().is_empty())
2544 .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
2545 std::fs::create_dir_all(parent)
2546 .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
2547 let mut temporary = tempfile::Builder::new()
2548 .prefix(".mbx-")
2549 .tempfile_in(parent)?;
2550 use std::io::Write as _;
2551 temporary.write_all(contents)?;
2552 temporary.as_file_mut().sync_all()?;
2553 temporary
2554 .persist(path)
2555 .map_err(|error| error.error)
2556 .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
2557 Ok(())
2558}