1mod events;
12
13use eyre::{Context, Result};
14use mbx_cache_core::{
15 CacheDigest, CacheDirectory, LocalCas, RemoteActionResult, RustcMetadata, is_task_identity,
16 task_manifest_actions,
17};
18use serde::{Deserialize, Serialize};
19use std::collections::{BTreeMap, BTreeSet, HashSet};
20use std::path::{Path, PathBuf};
21use std::time::{Duration, SystemTime, UNIX_EPOCH};
22
23const CAS_DIR: &str = "cas/v1";
24const ACTION_RESULTS_DIR: &str = "action-results/v1";
25const CHECKOUTS_DIR: &str = "checkouts/v1";
26const SWEEP_STAMP: &str = "gc/v1/last-sweep";
27const SWEEP_LOCK: &str = "gc/v1/sweep.lock";
28const CHECKOUT_RECORD_VERSION: u8 = 1;
29
30const SESSION_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
31const MAX_SESSIONS: usize = 256;
32
33const CHECKOUT_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60);
43
44#[derive(Debug, Default, PartialEq, Eq)]
45pub struct StoreStats {
46 pub objects: u64,
47 pub object_bytes: u64,
48 pub action_results: u64,
49 pub action_result_bytes: u64,
50 pub live_checkouts: u64,
51 pub stale_checkouts: u64,
56}
57
58impl StoreStats {
59 pub fn total_bytes(&self) -> u64 {
60 self.object_bytes.saturating_add(self.action_result_bytes)
61 }
62}
63
64#[derive(Debug, Default, PartialEq, Eq)]
65pub struct GcOutcome {
66 pub removed_objects: u64,
67 pub removed_action_results: u64,
68 pub removed_checkout_records: u64,
69 pub removed_session_streams: u64,
72 pub removed_bytes: u64,
73 pub remaining_bytes: u64,
74}
75
76#[derive(Debug, PartialEq, Eq)]
77pub struct ProjectUsage {
78 pub workspace_root: PathBuf,
79 pub identities: u64,
80 pub action_bytes: u64,
81 pub target_bytes: u64,
82 pub live: bool,
83}
84
85#[derive(Debug, PartialEq, Eq)]
86pub struct LargestEntry {
87 pub kind: &'static str,
88 pub path: PathBuf,
89 pub bytes: u64,
90}
91
92#[derive(Debug, Default, PartialEq, Eq)]
93pub struct VerifyOutcome {
94 pub checked_objects: u64,
95 pub checked_action_results: u64,
96 pub problems: Vec<PathBuf>,
97}
98
99#[derive(Debug, Default, PartialEq, Eq)]
100pub struct RemoveProjectOutcome {
101 pub removed_checkout_records: u64,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(deny_unknown_fields)]
112struct CheckoutRecord {
113 version: u8,
114 workspace_root: PathBuf,
115 target_dir: PathBuf,
116 updated_secs: u64,
117}
118
119pub fn stats(store: &Path) -> Result<StoreStats> {
121 let objects = walk_files(&store.join(CAS_DIR))?;
122 let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
123 let checkouts = scan_checkouts(store)?;
124 Ok(StoreStats {
125 objects: objects.len() as u64,
126 object_bytes: objects.iter().map(|entry| entry.size).sum(),
127 action_results: results.len() as u64,
128 action_result_bytes: results.iter().map(|entry| entry.size).sum(),
129 live_checkouts: checkouts.live_records,
130 stale_checkouts: checkouts.stale_records.len() as u64,
131 })
132}
133
134fn checkout_target_bytes(sizes: &mut BTreeMap<PathBuf, u64>, record: &CheckoutRecord) -> u64 {
141 if record.target_dir == record.workspace_root {
142 return 0;
143 }
144 cached_tree_bytes(sizes, &record.target_dir)
145}
146
147pub fn projects(store: &Path) -> Result<Vec<ProjectUsage>> {
153 let mut projects: BTreeMap<PathBuf, (BTreeSet<String>, bool, u64)> = BTreeMap::new();
154 let mut target_sizes = BTreeMap::new();
155 let root = store.join(CHECKOUTS_DIR);
156 for identity_entry in read_dir_or_empty(&root)? {
157 let identity = identity_entry.file_name().to_string_lossy().into_owned();
158 if !is_task_identity(&identity) || !identity_entry.file_type()?.is_dir() {
159 continue;
160 }
161 for entry in walk_files(&identity_entry.path())? {
162 let Some(record) = read_checkout_record(&entry.path) else {
163 continue;
164 };
165 let project = projects.entry(record.workspace_root.clone()).or_default();
166 if claim_is_live(&record) {
167 project.0.insert(identity.clone());
168 project.1 = true;
169 }
170 project.2 = project
171 .2
172 .max(checkout_target_bytes(&mut target_sizes, &record));
173 }
174 }
175 let action_cache = mbx_cache_core::LocalActionCache::new(store);
176 let mut usages = Vec::new();
177 for (workspace_root, (identities, live, target_bytes)) in projects {
178 let mut paths = rooted_objects(store, &identities)?;
179 for identity in &identities {
180 for action in task_manifest_actions(store, identity).unwrap_or_default() {
181 if let Ok(path) = action_cache.path_for(&action) {
182 paths.insert(path);
183 }
184 }
185 }
186 let action_bytes = paths
187 .iter()
188 .filter_map(|path| std::fs::metadata(path).ok())
189 .map(|metadata| metadata.len())
190 .sum();
191 usages.push(ProjectUsage {
192 workspace_root,
193 identities: identities.len() as u64,
194 action_bytes,
195 target_bytes,
196 live,
197 });
198 }
199 usages.sort_by(|left, right| {
200 right
201 .action_bytes
202 .saturating_add(right.target_bytes)
203 .cmp(&left.action_bytes.saturating_add(left.target_bytes))
204 .then_with(|| left.workspace_root.cmp(&right.workspace_root))
205 });
206 Ok(usages)
207}
208
209pub fn largest(store: &Path, limit: usize) -> Result<Vec<LargestEntry>> {
211 let mut entries = Vec::new();
212 for (kind, root, action_result) in [
213 ("object", store.join(CAS_DIR), false),
214 ("action result", store.join(ACTION_RESULTS_DIR), true),
215 ] {
216 entries.extend(
217 walk_files(&root)?
218 .into_iter()
219 .filter(|entry| addressed_digest(store, &entry.path, action_result).is_some())
220 .map(|entry| LargestEntry {
221 kind,
222 path: entry.path,
223 bytes: entry.size,
224 }),
225 );
226 }
227 entries.sort_by(|left, right| {
228 right
229 .bytes
230 .cmp(&left.bytes)
231 .then_with(|| left.path.cmp(&right.path))
232 });
233 entries.truncate(limit);
234 Ok(entries)
235}
236
237pub fn verify(store: &Path) -> Result<VerifyOutcome> {
239 let cas = LocalCas::new(store);
240 let action_cache = mbx_cache_core::LocalActionCache::new(store);
241 let virtually_removed = HashSet::new();
244 let mut outcome = VerifyOutcome::default();
245 for entry in walk_files(&store.join(CAS_DIR))? {
246 let Some(digest) = addressed_digest(store, &entry.path, false) else {
247 continue;
248 };
249 outcome.checked_objects += 1;
250 if !matches!(cas.find(&digest), Ok(Some(_))) {
251 outcome.problems.push(entry.path);
252 }
253 }
254 for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
255 let Some(digest) = addressed_digest(store, &entry.path, true) else {
256 continue;
257 };
258 outcome.checked_action_results += 1;
259 if !matches!(action_cache.find(&digest), Ok(Some(_)))
260 || action_result_is_dangling(&cas, &entry.path, &virtually_removed)?
261 {
262 outcome.problems.push(entry.path);
263 }
264 }
265 outcome.problems.sort();
266 Ok(outcome)
267}
268
269pub fn remove_project(store: &Path, workspace_root: &Path) -> Result<RemoveProjectOutcome> {
271 let mut outcome = RemoveProjectOutcome::default();
272 for entry in walk_files(&store.join(CHECKOUTS_DIR))? {
273 if read_checkout_record(&entry.path)
274 .is_some_and(|record| record.workspace_root == workspace_root)
275 {
276 std::fs::remove_file(&entry.path)?;
277 outcome.removed_checkout_records += 1;
278 if let Some(parent) = entry.path.parent() {
279 let _ = std::fs::remove_dir(parent);
280 }
281 }
282 }
283 Ok(outcome)
284}
285
286pub fn record_checkout(
298 store: &Path,
299 identity: &str,
300 workspace_root: &Path,
301 target_dir: &Path,
302) -> Result<()> {
303 let record = CheckoutRecord {
304 version: CHECKOUT_RECORD_VERSION,
305 workspace_root: workspace_root.to_path_buf(),
306 target_dir: target_dir.to_path_buf(),
307 updated_secs: SystemTime::now()
308 .duration_since(UNIX_EPOCH)
309 .map(|since| since.as_secs())
310 .unwrap_or_default(),
311 };
312 let mut contents = serde_json::to_vec(&record)?;
313 contents.push(b'\n');
314 write_atomic(
315 &checkout_record_path(store, identity, workspace_root),
316 &contents,
317 )
318}
319
320fn checkout_record_path(store: &Path, identity: &str, workspace_root: &Path) -> PathBuf {
321 let key = CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash;
325 store
326 .join(CHECKOUTS_DIR)
327 .join(identity)
328 .join(format!("{key}.json"))
329}
330
331pub fn gc(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
341 gc_with_mode(store, max_bytes, false)
342}
343
344pub fn gc_dry_run(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
346 gc_with_mode(store, max_bytes, true)
347}
348
349fn gc_with_mode(store: &Path, max_bytes: u64, dry_run: bool) -> Result<GcOutcome> {
350 let mut objects = walk_files(&store.join(CAS_DIR))?;
351 let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
352 let mut live_bytes = objects
353 .iter()
354 .chain(results.iter())
355 .map(|entry| entry.size)
356 .sum::<u64>();
357
358 let mut outcome = GcOutcome::default();
359
360 let checkouts = scan_checkouts(store)?;
363 for path in &checkouts.stale_records {
364 if dry_run || matches!(remove(path)?, Removal::Removed) {
365 outcome.removed_checkout_records += 1;
366 }
367 if !dry_run && let Some(parent) = path.parent() {
370 let _ = std::fs::remove_dir(parent);
371 }
372 }
373
374 let mut virtually_removed = HashSet::new();
375 if live_bytes > max_bytes {
376 let rooted = rooted_objects(store, &checkouts.live_identities)?;
380 objects.sort_by_cached_key(|entry| (rooted.contains(&entry.path), entry.used));
384 let evicted = evict_objects(&objects, &rooted, live_bytes, max_bytes, |path| {
385 if dry_run {
386 virtually_removed.insert(path.to_path_buf());
387 Ok(Removal::Removed)
388 } else {
389 remove(path)
390 }
391 })?;
392 live_bytes = evicted.remaining_bytes;
393 outcome.removed_objects += evicted.removed_objects;
394 outcome.removed_bytes += evicted.removed_bytes;
395 }
396
397 let cas = LocalCas::new(store);
402 for entry in &results {
403 if action_result_is_dangling(&cas, &entry.path, &virtually_removed)? {
404 match if dry_run {
405 Removal::Removed
406 } else {
407 remove(&entry.path)?
408 } {
409 Removal::Removed => {
410 live_bytes = live_bytes.saturating_sub(entry.size);
411 outcome.removed_action_results += 1;
412 outcome.removed_bytes += entry.size;
413 }
414 Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
415 Removal::Blocked => {}
416 }
417 }
418 }
419
420 let sessions = prune_sessions(store, dry_run)?;
421 outcome.removed_session_streams += sessions.removed_streams;
422 outcome.removed_bytes += sessions.removed_bytes;
423
424 outcome.remaining_bytes = live_bytes;
425 Ok(outcome)
426}
427
428#[derive(Debug, Default, PartialEq, Eq)]
429struct SessionPrune {
430 removed_streams: u64,
431 removed_bytes: u64,
432}
433
434fn prune_sessions(store: &Path, dry_run: bool) -> Result<SessionPrune> {
435 let mut prune = SessionPrune::default();
436 let ids = events::session_ids(store);
437 let surplus = ids.len().saturating_sub(MAX_SESSIONS);
438 for (index, id) in ids.iter().enumerate() {
439 let paths = events::session_paths(store, id);
440 let metadata = match std::fs::metadata(&paths.events) {
441 Ok(metadata) => metadata,
442 Err(_) => continue,
443 };
444 let stale = metadata
445 .modified()
446 .ok()
447 .and_then(|at| SystemTime::now().duration_since(at).ok())
448 .is_some_and(|age| age > SESSION_RETENTION);
449 if index >= surplus && !stale {
450 continue;
451 }
452 if events::session_is_live(store, id) {
453 continue;
454 }
455 if dry_run {
456 prune.removed_streams += 1;
457 prune.removed_bytes += metadata.len();
458 continue;
459 }
460 if matches!(remove(&paths.events)?, Removal::Removed) {
461 prune.removed_streams += 1;
462 prune.removed_bytes += metadata.len();
463 let _ = std::fs::remove_file(&paths.lock);
464 }
465 }
466 if !dry_run {
467 for lock in events::orphaned_locks(store) {
468 let _ = std::fs::remove_file(lock);
469 }
470 }
471 Ok(prune)
472}
473
474pub fn sweep_if_due(store: &Path, max_bytes: u64, interval: Duration) -> Result<Option<GcOutcome>> {
480 if !claim_sweep(store, interval)? {
481 return Ok(None);
482 }
483 gc(store, max_bytes).map(Some)
484}
485
486pub fn claim_sweep(store: &Path, interval: Duration) -> Result<bool> {
488 let lock_path = store.join(SWEEP_LOCK);
489 std::fs::create_dir_all(lock_path.parent().expect("sweep lock has a parent"))?;
490 let mut lock = fslock::LockFile::open(&lock_path)?;
491 lock.lock()?;
492
493 let stamp = store.join(SWEEP_STAMP);
494 if let Ok(metadata) = std::fs::metadata(&stamp)
495 && let Ok(modified) = metadata.modified()
496 && let Ok(since) = modified.elapsed()
497 && since < interval
498 {
499 return Ok(false);
500 }
501 write_atomic(&stamp, b"")?;
502 Ok(true)
503}
504
505struct CheckoutScan {
507 live_identities: BTreeSet<String>,
508 live_records: u64,
509 stale_records: Vec<PathBuf>,
510}
511
512fn scan_checkouts(store: &Path) -> Result<CheckoutScan> {
513 let root = store.join(CHECKOUTS_DIR);
514 let mut scan = CheckoutScan {
515 live_identities: BTreeSet::new(),
516 live_records: 0,
517 stale_records: Vec::new(),
518 };
519 let listing = match std::fs::read_dir(&root) {
520 Ok(listing) => listing,
521 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(scan),
522 Err(error) => {
523 return Err(error).wrap_err_with(|| format!("failed to read {}", root.display()));
524 }
525 };
526 for entry in listing {
527 let entry = entry?;
528 let name = entry.file_name().to_string_lossy().into_owned();
529 if !is_task_identity(&name) || !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
535 continue;
536 }
537 for record in walk_files(&entry.path())? {
538 match read_checkout_record(&record.path) {
539 Some(checkout) if claim_is_live(&checkout) => {
540 scan.live_identities.insert(name.clone());
541 scan.live_records += 1;
542 }
543 Some(_) => scan.stale_records.push(record.path),
544 None => {}
547 }
548 }
549 }
550 Ok(scan)
551}
552
553fn read_checkout_record(path: &Path) -> Option<CheckoutRecord> {
554 let bytes = std::fs::read(path).ok()?;
555 let record = serde_json::from_slice::<CheckoutRecord>(&bytes).ok()?;
556 (record.version == CHECKOUT_RECORD_VERSION).then_some(record)
557}
558
559fn claim_is_live(record: &CheckoutRecord) -> bool {
562 checkout_is_live(&record.workspace_root) && !claim_has_expired(record)
563}
564
565fn claim_has_expired(record: &CheckoutRecord) -> bool {
566 let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
567 return false;
568 };
569 now.as_secs()
570 .saturating_sub(record.updated_secs)
571 .gt(&CHECKOUT_RETENTION.as_secs())
572}
573
574pub fn checkout_is_live(workspace_root: &Path) -> bool {
588 if !matches!(workspace_root.try_exists(), Ok(false)) {
589 return true;
590 }
591 match workspace_root.parent() {
592 Some(parent) => !matches!(parent.try_exists(), Ok(true)),
593 None => true,
595 }
596}
597
598fn rooted_objects(store: &Path, identities: &BTreeSet<String>) -> Result<HashSet<PathBuf>> {
606 let cas = LocalCas::new(store);
607 let mut rooted = HashSet::new();
608 let mut visited = BTreeSet::new();
609 for identity in identities {
610 let actions = match task_manifest_actions(store, identity) {
613 Ok(actions) => actions,
614 Err(error) => {
615 log::debug!("could not read the manifest for {identity}: {error}");
616 continue;
617 }
618 };
619 for action in actions {
620 let Some(result) = read_action_result(store, &action) else {
621 continue;
622 };
623 root_digest(&cas, &mut rooted, &result.action);
624 if let Some(metadata) = &result.metadata {
625 root_digest(&cas, &mut rooted, metadata);
626 if let Some(rustc) = read_rustc_metadata(&cas, metadata) {
633 root_digest(&cas, &mut rooted, &rustc.stdout);
634 root_digest(&cas, &mut rooted, &rustc.stderr);
635 }
636 }
637 if let Some(output_root) = &result.output_root {
638 root_digest(&cas, &mut rooted, output_root);
639 root_tree(&cas, output_root, &mut rooted, &mut visited);
640 }
641 }
642 }
643 Ok(rooted)
644}
645
646fn read_action_result(store: &Path, action: &CacheDigest) -> Option<RemoteActionResult> {
647 let path = mbx_cache_core::LocalActionCache::new(store)
648 .path_for(action)
649 .ok()?;
650 let bytes = std::fs::read(path).ok()?;
651 serde_json::from_slice(&bytes).ok()
652}
653
654fn root_tree(
655 cas: &LocalCas,
656 output_root: &CacheDigest,
657 rooted: &mut HashSet<PathBuf>,
658 visited: &mut BTreeSet<CacheDigest>,
659) {
660 let mut pending = vec![output_root.clone()];
661 while let Some(digest) = pending.pop() {
662 if !visited.insert(digest.clone()) {
663 continue;
664 }
665 let Some(directory) = read_directory(cas, &digest) else {
666 continue;
667 };
668 for file in &directory.files {
669 root_digest(cas, rooted, &file.digest);
670 }
671 for child in &directory.directories {
672 root_digest(cas, rooted, &child.digest);
673 pending.push(child.digest.clone());
674 }
675 }
676}
677
678fn read_rustc_metadata(cas: &LocalCas, digest: &CacheDigest) -> Option<RustcMetadata> {
679 let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
680 serde_json::from_slice(&bytes).ok()
681}
682
683fn read_directory(cas: &LocalCas, digest: &CacheDigest) -> Option<CacheDirectory> {
684 let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
685 serde_json::from_slice(&bytes).ok()
686}
687
688fn root_digest(cas: &LocalCas, rooted: &mut HashSet<PathBuf>, digest: &CacheDigest) {
689 if let Ok(path) = cas.path_for(digest) {
690 rooted.insert(path);
691 }
692}
693
694#[derive(Clone, Copy, Debug, PartialEq, Eq)]
695enum Removal {
696 Removed,
697 Missing,
698 Blocked,
699}
700
701fn remove(path: &Path) -> Result<Removal> {
702 match std::fs::remove_file(path) {
703 Ok(()) => Ok(Removal::Removed),
704 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Removal::Missing),
706 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
711 log::debug!("could not evict {}: {error}", path.display());
712 Ok(Removal::Blocked)
713 }
714 Err(error) => Err(error).wrap_err_with(|| format!("failed to evict {}", path.display())),
715 }
716}
717
718#[derive(Debug, Default, PartialEq, Eq)]
719struct ObjectEvictions {
720 removed_objects: u64,
721 removed_bytes: u64,
722 remaining_bytes: u64,
723}
724
725fn evict_objects(
728 objects: &[Entry],
729 rooted: &HashSet<PathBuf>,
730 mut live_bytes: u64,
731 max_bytes: u64,
732 mut remove_file: impl FnMut(&Path) -> Result<Removal>,
733) -> Result<ObjectEvictions> {
734 let mut outcome = ObjectEvictions::default();
735 let mut blocked_unrooted = false;
736 for entry in objects {
737 if live_bytes <= max_bytes {
738 break;
739 }
740 let is_rooted = rooted.contains(&entry.path);
741 if is_rooted && blocked_unrooted {
742 break;
746 }
747 match remove_file(&entry.path)? {
748 Removal::Removed => {
749 live_bytes = live_bytes.saturating_sub(entry.size);
750 outcome.removed_objects += 1;
751 outcome.removed_bytes += entry.size;
752 }
753 Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
754 Removal::Blocked if !is_rooted => blocked_unrooted = true,
755 Removal::Blocked => {}
756 }
757 }
758 outcome.remaining_bytes = live_bytes;
759 Ok(outcome)
760}
761
762fn action_result_is_dangling(
780 cas: &LocalCas,
781 path: &Path,
782 virtually_removed: &HashSet<PathBuf>,
783) -> Result<bool> {
784 let bytes = match std::fs::read(path) {
785 Ok(bytes) => bytes,
786 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
787 Err(error) => {
788 return Err(error).wrap_err_with(|| format!("failed to read {}", path.display()));
789 }
790 };
791 let Ok(result) = serde_json::from_slice::<RemoteActionResult>(&bytes) else {
793 return Ok(false);
794 };
795 for digest in [
796 Some(&result.action),
797 result.metadata.as_ref(),
798 result.output_root.as_ref(),
799 ]
800 .into_iter()
801 .flatten()
802 {
803 match cas.path_for(digest) {
804 Ok(path) => {
805 if virtually_removed.contains(&path) || !path.exists() {
806 return Ok(true);
807 }
808 }
809 Err(_) => return Ok(true),
811 }
812 }
813 Ok(false)
814}
815
816struct Entry {
817 path: PathBuf,
818 size: u64,
819 used: SystemTime,
820}
821
822fn walk_files(root: &Path) -> Result<Vec<Entry>> {
823 let mut entries = Vec::new();
824 let mut pending = vec![root.to_path_buf()];
825 while let Some(directory) = pending.pop() {
826 let listing = match std::fs::read_dir(&directory) {
827 Ok(listing) => listing,
828 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
829 Err(error) => {
830 return Err(error)
831 .wrap_err_with(|| format!("failed to read {}", directory.display()));
832 }
833 };
834 for entry in listing {
835 let entry = entry?;
836 let metadata = match entry.metadata() {
837 Ok(metadata) => metadata,
838 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
840 Err(error) => return Err(error.into()),
841 };
842 if metadata.is_dir() {
843 pending.push(entry.path());
844 } else if metadata.is_file() {
845 let used = metadata
846 .accessed()
847 .or_else(|_| metadata.modified())
848 .unwrap_or(SystemTime::UNIX_EPOCH);
849 entries.push(Entry {
850 path: entry.path(),
851 size: metadata.len(),
852 used,
853 });
854 }
855 }
856 }
857 Ok(entries)
858}
859
860fn read_dir_or_empty(root: &Path) -> Result<Vec<std::fs::DirEntry>> {
861 match std::fs::read_dir(root) {
862 Ok(entries) => entries
863 .collect::<std::io::Result<Vec<_>>>()
864 .map_err(Into::into),
865 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
866 Err(error) => Err(error).wrap_err_with(|| format!("failed to read {}", root.display())),
867 }
868}
869
870fn digest_from_path(path: &Path, action_result: bool) -> Option<CacheDigest> {
871 let name = path.file_name()?.to_str()?;
872 let name = if action_result {
873 name.strip_suffix(".json")?
874 } else {
875 name
876 };
877 let (hash, size) = name.rsplit_once('-')?;
878 let algorithm = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
879 let digest = CacheDigest {
880 algorithm,
881 hash: hash.to_string(),
882 size: size.parse().ok()?,
883 };
884 digest.validate().ok()?;
885 Some(digest)
886}
887
888fn addressed_digest(store: &Path, path: &Path, action_result: bool) -> Option<CacheDigest> {
889 let digest = digest_from_path(path, action_result)?;
890 let canonical = if action_result {
891 mbx_cache_core::LocalActionCache::new(store)
892 .path_for(&digest)
893 .ok()?
894 } else {
895 LocalCas::new(store).path_for(&digest).ok()?
896 };
897 (canonical == path).then_some(digest)
898}
899
900fn cached_tree_bytes(cache: &mut BTreeMap<PathBuf, u64>, root: &Path) -> u64 {
901 *cache
902 .entry(root.to_path_buf())
903 .or_insert_with(|| tree_bytes(root))
904}
905
906fn tree_bytes(root: &Path) -> u64 {
907 let Ok(root_metadata) = std::fs::metadata(root) else {
908 return 0;
909 };
910 if root_metadata.is_file() {
911 return root_metadata.len();
912 }
913 let Ok(entries) = std::fs::read_dir(root) else {
914 return 0;
915 };
916 let mut bytes = 0_u64;
917 let mut pending = entries
918 .filter_map(std::result::Result::ok)
919 .map(|entry| entry.path())
920 .collect::<Vec<_>>();
921 while let Some(path) = pending.pop() {
922 let Ok(metadata) = std::fs::symlink_metadata(&path) else {
923 continue;
924 };
925 if metadata.is_file() {
926 bytes = bytes.saturating_add(metadata.len());
927 } else if metadata.is_dir()
928 && let Ok(entries) = std::fs::read_dir(path)
929 {
930 pending.extend(
931 entries
932 .filter_map(std::result::Result::ok)
933 .map(|entry| entry.path()),
934 );
935 }
936 }
937 bytes
938}
939
940#[cfg(test)]
941#[path = "store_tests.rs"]
942mod tests;
943
944fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
945 let parent = path
946 .parent()
947 .filter(|parent| !parent.as_os_str().is_empty())
948 .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
949 std::fs::create_dir_all(parent)
950 .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
951 let mut temporary = tempfile::Builder::new()
952 .prefix(".mbx-")
953 .tempfile_in(parent)?;
954 use std::io::Write as _;
955 temporary.write_all(contents)?;
956 temporary.as_file_mut().sync_all()?;
957 temporary
958 .persist(path)
959 .map_err(|error| error.error)
960 .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
961 Ok(())
962}