Skip to main content

mbx_cache_store/
lib.rs

1//! Store inspection and garbage collection.
2//!
3//! The store holds six trees: `cas/v1` for content-addressed objects,
4//! `action-results/v1` for the results that reference them, `task-manifests/v1`
5//! for the prediction index, `checkouts/v1` for the checkouts that have built
6//! each identity, `build-receipts/v1` for exact completed build closures, and
7//! `sessions/v1` for per-build event streams. Only the first two are collected
8//! for size; manifests and receipts are small, checkout records expire with
9//! their checkout claims, and session streams are bounded by age and count
10//! because they are history rather than cache content.
11
12mod events;
13
14use eyre::{Context, Result};
15use mbx_cache_core::{
16    ActionPrediction, CacheDigest, CacheDirectory, LocalCas, RemoteActionResult, RustcMetadata,
17    TaskActionManifest, is_task_identity, merge_task_action_predictions, task_manifest_actions,
18};
19use serde::{Deserialize, Serialize};
20use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
21use std::path::{Component, Path, PathBuf};
22use std::sync::atomic::{AtomicUsize, Ordering};
23use std::sync::{Arc, Mutex};
24use std::time::{Duration, SystemTime, UNIX_EPOCH};
25
26const CAS_DIR: &str = "cas/v1";
27const ACTION_RESULTS_DIR: &str = "action-results/v1";
28const CHECKOUTS_DIR: &str = "checkouts/v1";
29const SWEEP_STAMP: &str = "gc/v1/last-sweep";
30const SWEEP_LOCK: &str = "gc/v1/sweep.lock";
31const CHECKOUT_RECORD_VERSION: u8 = 1;
32const BUILD_RECEIPTS_DIR: &str = "build-receipts/v1";
33const BUILD_RECEIPT_VERSION: u8 = 1;
34const IMPORT_STAGING_DIR: &str = "import-staging";
35const EXPORT_MANIFEST: &str = "mbx-cache-export-v1.json";
36const EXPORT_VERSION: u8 = 2;
37const LEGACY_EXPORT_VERSION: u8 = 1;
38
39const IMPORT_STAGING_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
40const SESSION_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
41const MAX_SESSIONS: usize = 256;
42
43/// How long a checkout's claim outlives the last build that renewed it.
44///
45/// Existence alone is not enough to keep a claim alive. An identity covers one
46/// exact command line against one exact lockfile, so a checkout that lives for
47/// years accumulates an identity per lockfile it ever had -- and every one of
48/// them would go on rooting the actions of a build nobody will run again.
49/// Without this bound the rooted set only grows, until everything is rooted and
50/// the ordering means nothing. A build renews the claims it uses, so anything
51/// this stale belongs to a command that has moved on.
52const CHECKOUT_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60);
53
54#[derive(Debug, Default, PartialEq, Eq)]
55pub struct StoreStats {
56    pub objects: u64,
57    pub object_bytes: u64,
58    pub action_results: u64,
59    pub action_result_bytes: u64,
60    pub live_checkouts: u64,
61    /// Claims that root nothing any more: a checkout that is gone, or one that
62    /// is still there but has not renewed this claim inside the retention
63    /// window. Reported as stale rather than gone because those are different
64    /// things and only one of them means the directory is missing.
65    pub stale_checkouts: u64,
66}
67
68impl StoreStats {
69    pub fn total_bytes(&self) -> u64 {
70        self.object_bytes.saturating_add(self.action_result_bytes)
71    }
72}
73
74#[derive(Debug, Default, PartialEq, Eq)]
75pub struct GcOutcome {
76    pub removed_objects: u64,
77    pub removed_action_results: u64,
78    pub removed_checkout_records: u64,
79    /// Event streams dropped on age or count. These bytes are not included in
80    /// `remaining_bytes` because session history is not cache content.
81    pub removed_session_streams: u64,
82    pub removed_bytes: u64,
83    pub remaining_bytes: u64,
84}
85
86#[derive(Debug, PartialEq, Eq)]
87pub struct ProjectUsage {
88    pub workspace_root: PathBuf,
89    pub identities: u64,
90    pub action_bytes: u64,
91    pub target_bytes: u64,
92    pub live: bool,
93}
94
95#[derive(Debug, PartialEq, Eq)]
96pub struct LargestEntry {
97    pub kind: &'static str,
98    pub path: PathBuf,
99    pub bytes: u64,
100}
101
102#[derive(Debug, Default, PartialEq, Eq)]
103pub struct VerifyOutcome {
104    pub checked_objects: u64,
105    pub checked_action_results: u64,
106    pub problems: Vec<PathBuf>,
107}
108
109#[derive(Debug, Default, PartialEq, Eq)]
110pub struct RemoveProjectOutcome {
111    pub removed_checkout_records: u64,
112}
113
114#[derive(Debug, Default, PartialEq, Eq)]
115pub struct TransferOutcome {
116    pub actions: u64,
117    pub objects: u64,
118    pub bytes: u64,
119}
120
121/// A cache import together with named higher-level CAS roots it carried.
122#[derive(Debug)]
123#[non_exhaustive]
124pub struct ImportOutcome {
125    pub transfer: TransferOutcome,
126    pub attachments: BTreeMap<String, CacheDigest>,
127}
128
129/// Extra CAS roots carried by a cache export for a higher-level transport.
130#[derive(Debug, Default)]
131pub struct ExportAdditions {
132    /// Stable names by which the importer can discover selected objects.
133    pub attachments: BTreeMap<String, CacheDigest>,
134    /// Every object the attachments reach, including the named objects.
135    pub objects: BTreeSet<CacheDigest>,
136}
137
138/// One Cargo workspace and the target directory recorded for its build.
139#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
140pub struct WorkspaceTarget {
141    pub workspace_root: PathBuf,
142    pub target_dir: PathBuf,
143}
144
145#[derive(Debug, Serialize, Deserialize)]
146#[serde(deny_unknown_fields)]
147struct ExportManifest {
148    version: u8,
149    tasks: Vec<TaskActionManifest>,
150    actions: Vec<CacheDigest>,
151    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
152    attachments: BTreeMap<String, CacheDigest>,
153    #[serde(default, skip_serializing_if = "Vec::is_empty")]
154    objects: Vec<CacheDigest>,
155}
156
157/// The exact cache predictions completed by one top-level build command.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159#[serde(deny_unknown_fields)]
160struct BuildReceipt {
161    version: u8,
162    workspace_root: PathBuf,
163    identity: String,
164    completed_nanos: u64,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    group: Option<String>,
167    predictions: Vec<ActionPrediction>,
168}
169
170/// Record an exact completed build for checkout and grouped exports.
171pub fn record_build_receipt(
172    store: &Path,
173    run: &str,
174    identity: &str,
175    workspace_root: &Path,
176    group: Option<&str>,
177    predictions: Vec<ActionPrediction>,
178) -> Result<()> {
179    if !is_task_identity(run) || !is_task_identity(identity) {
180        eyre::bail!("invalid build receipt identity");
181    }
182    if !(TaskActionManifest {
183        version: 1,
184        task: identity.to_owned(),
185        predictions: predictions.clone(),
186    })
187    .validate()
188    {
189        eyre::bail!("invalid build receipt prediction");
190    }
191    if let Some(group) = group {
192        validate_export_group(group)?;
193    }
194    // A build that used exactly what the last one did leaves that receipt
195    // standing. Only its timestamp would change, and the export it feeds
196    // wants the prediction set, not the hour. A group receipt is per run and
197    // always written.
198    if group.is_none()
199        && read_build_receipt(&latest_receipt_path(store, workspace_root)).is_some_and(|latest| {
200            latest.identity == identity
201                && latest.workspace_root == workspace_root
202                && latest.group.is_none()
203                && latest.predictions == predictions
204        })
205    {
206        return Ok(());
207    }
208    let key = workspace_key(workspace_root);
209    let lock_path = store
210        .join(BUILD_RECEIPTS_DIR)
211        .join("locks")
212        .join(format!("{key}.lock"));
213    std::fs::create_dir_all(lock_path.parent().expect("receipt lock has a parent"))?;
214    let mut lock = fslock::LockFile::open(&lock_path)?;
215    lock.lock()?;
216    let completed_nanos = SystemTime::now()
217        .duration_since(UNIX_EPOCH)
218        .map(|elapsed| elapsed.as_nanos().try_into().unwrap_or(u64::MAX))
219        .unwrap_or_default();
220    let receipt = BuildReceipt {
221        version: BUILD_RECEIPT_VERSION,
222        workspace_root: workspace_root.to_path_buf(),
223        identity: identity.to_owned(),
224        completed_nanos,
225        group: group.map(str::to_owned),
226        predictions,
227    };
228    let bytes = serde_json::to_vec(&receipt)?;
229    write_atomic(&latest_receipt_path(store, workspace_root), &bytes)?;
230    if let Some(group) = group {
231        write_atomic(&group_receipt_path(store, group, run), &bytes)?;
232    }
233    Ok(())
234}
235
236fn workspace_key(workspace_root: &Path) -> String {
237    CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash
238}
239
240fn latest_receipt_path(store: &Path, workspace_root: &Path) -> PathBuf {
241    store
242        .join(BUILD_RECEIPTS_DIR)
243        .join("checkouts")
244        .join(format!("{}.json", workspace_key(workspace_root)))
245}
246
247fn group_receipt_path(store: &Path, group: &str, run: &str) -> PathBuf {
248    store
249        .join(BUILD_RECEIPTS_DIR)
250        .join("groups")
251        .join(group_key(group))
252        .join(format!("{run}.json"))
253}
254
255fn group_key(group: &str) -> String {
256    CacheDigest::blake3(group.as_bytes()).hash
257}
258
259fn read_build_receipt(path: &Path) -> Option<BuildReceipt> {
260    let bytes = std::fs::read(path).ok()?;
261    let receipt = serde_json::from_slice::<BuildReceipt>(&bytes).ok()?;
262    let valid = TaskActionManifest {
263        version: 1,
264        task: receipt.identity.clone(),
265        predictions: receipt.predictions.clone(),
266    }
267    .validate();
268    (receipt.version == BUILD_RECEIPT_VERSION && valid).then_some(receipt)
269}
270
271fn validate_export_group(group: &str) -> Result<()> {
272    if group.is_empty() || group.len() > 256 || group.chars().any(char::is_control) {
273        eyre::bail!("invalid build export group");
274    }
275    Ok(())
276}
277
278/// How an export is laid out on disk.
279///
280/// Both forms carry the same manifest and the same `cas/v1` and
281/// `action-results/v1` layout; they differ only in whether that tree is
282/// wrapped in a tar. A transport that archives and compresses a directory
283/// itself, such as `actions/cache`, would otherwise write every byte twice:
284/// once into the tar and once again when the importer unpacks it.
285#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
286pub enum ExportForm {
287    /// One tar file: portable, and what a standalone bundle should be.
288    #[default]
289    Tar,
290    /// A directory tree, for transports that archive directories themselves.
291    Directory,
292}
293
294impl std::str::FromStr for ExportForm {
295    type Err = eyre::Report;
296
297    fn from_str(text: &str) -> Result<Self> {
298        match text {
299            "tar" => Ok(Self::Tar),
300            "directory" => Ok(Self::Directory),
301            other => eyre::bail!("unknown export format {other:?}; expected tar or directory"),
302        }
303    }
304}
305
306/// Export the complete local cache closure of this checkout's most recent build.
307pub fn export_checkout(
308    store: &Path,
309    workspace_root: &Path,
310    archive: &Path,
311) -> Result<TransferOutcome> {
312    let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
313        .filter(|receipt| receipt.workspace_root == workspace_root)
314        .ok_or_else(|| {
315            eyre::eyre!(
316                "no completed mbx build is recorded for {}",
317                workspace_root.display()
318            )
319        })?;
320    export_receipts(
321        store,
322        vec![receipt],
323        archive,
324        ExportAdditions::default(),
325        ExportForm::Tar,
326    )
327}
328
329/// Return the target directory recorded for this checkout's latest build.
330pub fn checkout_workspace_target(
331    store: &Path,
332    workspace_root: &Path,
333) -> Result<Option<WorkspaceTarget>> {
334    let Some(receipt) = read_build_receipt(&latest_receipt_path(store, workspace_root))
335        .filter(|receipt| receipt.workspace_root == workspace_root)
336    else {
337        return Ok(None);
338    };
339    Ok(workspace_target_for_receipt(store, &receipt))
340}
341
342/// Return every Cargo target represented by the pending receipts in a group.
343pub fn group_workspace_targets(store: &Path, group: &str) -> Result<Vec<WorkspaceTarget>> {
344    validate_export_group(group)?;
345    let root = store
346        .join(BUILD_RECEIPTS_DIR)
347        .join("groups")
348        .join(group_key(group));
349    let targets = walk_files(&root)?
350        .into_iter()
351        .filter_map(|entry| read_build_receipt(&entry.path))
352        .filter(|receipt| receipt.group.as_deref() == Some(group))
353        .filter_map(|receipt| workspace_target_for_receipt(store, &receipt))
354        .collect::<BTreeSet<_>>();
355    Ok(targets.into_iter().collect())
356}
357
358fn workspace_target_for_receipt(store: &Path, receipt: &BuildReceipt) -> Option<WorkspaceTarget> {
359    let record = read_checkout_record(&checkout_record_path(
360        store,
361        &receipt.identity,
362        &receipt.workspace_root,
363    ))?;
364    (record.workspace_root == receipt.workspace_root && record.target_dir != record.workspace_root)
365        .then_some(WorkspaceTarget {
366            workspace_root: record.workspace_root,
367            target_dir: record.target_dir,
368        })
369}
370
371/// Export one checkout's closure together with higher-level CAS attachments.
372pub fn export_checkout_with(
373    store: &Path,
374    workspace_root: &Path,
375    archive: &Path,
376    additions: ExportAdditions,
377) -> Result<TransferOutcome> {
378    let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
379        .filter(|receipt| receipt.workspace_root == workspace_root)
380        .ok_or_else(|| {
381            eyre::eyre!(
382                "no completed mbx build is recorded for {}",
383                workspace_root.display()
384            )
385        })?;
386    export_receipts(store, vec![receipt], archive, additions, ExportForm::Tar)
387}
388
389/// Export one checkout's closure in the requested form.
390pub fn export_checkout_as(
391    store: &Path,
392    workspace_root: &Path,
393    destination: &Path,
394    additions: ExportAdditions,
395    form: ExportForm,
396) -> Result<TransferOutcome> {
397    let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
398        .filter(|receipt| receipt.workspace_root == workspace_root)
399        .ok_or_else(|| {
400            eyre::eyre!(
401                "no completed mbx build is recorded for {}",
402                workspace_root.display()
403            )
404        })?;
405    export_receipts(store, vec![receipt], destination, additions, form)
406}
407
408/// Export the union of every completed build recorded under one CI group.
409pub fn export_group(store: &Path, group: &str, archive: &Path) -> Result<TransferOutcome> {
410    export_group_with(store, group, archive, ExportAdditions::default())
411}
412
413/// Export a grouped closure together with higher-level CAS attachments.
414pub fn export_group_with(
415    store: &Path,
416    group: &str,
417    archive: &Path,
418    additions: ExportAdditions,
419) -> Result<TransferOutcome> {
420    export_group_as(store, group, archive, additions, ExportForm::Tar)
421}
422
423/// Export a grouped closure in the requested form.
424pub fn export_group_as(
425    store: &Path,
426    group: &str,
427    destination: &Path,
428    additions: ExportAdditions,
429    form: ExportForm,
430) -> Result<TransferOutcome> {
431    validate_export_group(group)?;
432    let root = store
433        .join(BUILD_RECEIPTS_DIR)
434        .join("groups")
435        .join(group_key(group));
436    let receipts = walk_files(&root)?
437        .into_iter()
438        .filter_map(|entry| read_build_receipt(&entry.path).map(|receipt| (entry.path, receipt)))
439        .filter(|(_, receipt)| receipt.group.as_deref() == Some(group))
440        .collect::<Vec<_>>();
441    if receipts.is_empty() {
442        eyre::bail!("no completed mbx builds are recorded for export group {group:?}");
443    }
444    let outcome = export_receipts(
445        store,
446        receipts
447            .iter()
448            .map(|(_, receipt)| receipt.clone())
449            .collect(),
450        destination,
451        additions,
452        form,
453    )?;
454    // A receipt is a pending-export root. Retire only the files this export
455    // consumed, and only after its complete archive has been published. A
456    // concurrent build can add another uniquely named receipt to the group
457    // without this cleanup deleting work the archive did not include.
458    for (path, _) in receipts {
459        std::fs::remove_file(path)?;
460    }
461    let _ = std::fs::remove_dir(root);
462    Ok(outcome)
463}
464
465fn export_receipts(
466    store: &Path,
467    mut receipts: Vec<BuildReceipt>,
468    archive: &Path,
469    additions: ExportAdditions,
470    form: ExportForm,
471) -> Result<TransferOutcome> {
472    receipts.sort_by(|left, right| {
473        left.completed_nanos
474            .cmp(&right.completed_nanos)
475            .then_with(|| {
476                // Concurrent commands may complete on the same clock tick. Use
477                // their persisted predictions to break ties, not directory order.
478                fn key(prediction: &ActionPrediction) -> (&CacheDigest, &CacheDigest, &str, &str) {
479                    (
480                        &prediction.invocation,
481                        &prediction.action,
482                        &prediction.adapter,
483                        &prediction.payload,
484                    )
485                }
486                left.predictions
487                    .iter()
488                    .map(key)
489                    .cmp(right.predictions.iter().map(key))
490            })
491    });
492    let mut actions = BTreeSet::new();
493    let mut tasks = BTreeMap::new();
494    for receipt in receipts {
495        actions.extend(
496            receipt
497                .predictions
498                .iter()
499                .map(|prediction| prediction.action.clone()),
500        );
501        // Keep predictions from every command in receipt order. Deduplication
502        // below makes the most recently completed command win an overlap.
503        tasks
504            .entry(receipt.identity)
505            .or_insert_with(Vec::new)
506            .extend(receipt.predictions);
507    }
508    let tasks = tasks
509        .into_iter()
510        .map(|(task, predictions)| {
511            let mut invocations = BTreeSet::new();
512            let mut predictions = predictions
513                .into_iter()
514                .rev()
515                .filter(|prediction| invocations.insert(prediction.invocation.clone()))
516                .collect::<Vec<_>>();
517            predictions.reverse();
518            TaskActionManifest {
519                version: 1,
520                task,
521                predictions,
522            }
523        })
524        .collect::<Vec<_>>();
525    if tasks.iter().any(|task| !task.validate()) {
526        eyre::bail!("combined export predictions exceed task manifest limits");
527    }
528    validate_export_additions(&additions)?;
529    let mut closure = strict_closure(store, &actions)?;
530    let cas = LocalCas::new(store);
531    for digest in &additions.objects {
532        require_object(&cas, &mut closure, digest)?;
533    }
534    // Hash the leaves before they are packed. An export is what another
535    // machine will trust, so publishing a blob this store has since corrupted
536    // would move the failure to whoever restores it.
537    verify_pending(&mut closure.pending).wrap_err("cache closure is incomplete or corrupt")?;
538    let parent = archive
539        .parent()
540        .filter(|parent| !parent.as_os_str().is_empty())
541        .unwrap_or_else(|| Path::new("."));
542    std::fs::create_dir_all(parent)?;
543    let manifest = serde_json::to_vec(&ExportManifest {
544        version: if additions.attachments.is_empty() && additions.objects.is_empty() {
545            LEGACY_EXPORT_VERSION
546        } else {
547            EXPORT_VERSION
548        },
549        tasks,
550        actions: actions.iter().cloned().collect(),
551        attachments: additions.attachments.clone(),
552        objects: additions.objects.iter().cloned().collect(),
553    })?;
554    let members = closure
555        .objects
556        .iter()
557        .chain(closure.results.iter())
558        .collect::<Vec<_>>();
559    let bytes = match form {
560        ExportForm::Tar => write_tar_export(store, archive, parent, &manifest, &members)?,
561        ExportForm::Directory => {
562            write_directory_export(store, archive, parent, &manifest, &members)?
563        }
564    };
565    Ok(TransferOutcome {
566        actions: actions.len() as u64,
567        objects: closure.objects.len() as u64,
568        bytes,
569    })
570}
571
572/// Write the export as one tar file, published atomically by rename.
573fn write_tar_export(
574    store: &Path,
575    archive: &Path,
576    parent: &Path,
577    manifest: &[u8],
578    members: &[&PathBuf],
579) -> Result<u64> {
580    let temporary = tempfile::Builder::new()
581        .prefix(".mbx-export-")
582        .tempfile_in(parent)?;
583    let mut builder = tar::Builder::new(temporary.reopen()?);
584    append_bytes(&mut builder, Path::new(EXPORT_MANIFEST), manifest)?;
585    for path in members {
586        append_file(&mut builder, store, path)?;
587    }
588    builder.finish()?;
589    drop(builder);
590    temporary
591        .persist(archive)
592        .map_err(|error| error.error)
593        .wrap_err_with(|| format!("failed to publish {}", archive.display()))?;
594    Ok(std::fs::metadata(archive)?.len())
595}
596
597/// Write the export as a directory, published atomically by rename.
598///
599/// Built beside the destination and moved into place, so an interrupted export
600/// leaves a `.mbx-export-` directory rather than a bundle that looks complete
601/// and is not.
602fn write_directory_export(
603    store: &Path,
604    destination: &Path,
605    parent: &Path,
606    manifest: &[u8],
607    members: &[&PathBuf],
608) -> Result<u64> {
609    let staging = tempfile::Builder::new()
610        .prefix(".mbx-export-")
611        .tempdir_in(parent)?;
612    std::fs::write(staging.path().join(EXPORT_MANIFEST), manifest)?;
613    let mut bytes = manifest.len() as u64;
614    for path in members {
615        let relative = path.strip_prefix(store)?;
616        let target = staging.path().join(relative);
617        std::fs::create_dir_all(target.parent().expect("export member has a parent"))?;
618        bytes += std::fs::copy(path, &target)?;
619    }
620    let staged = staging.keep();
621    // `rename` will not replace a populated directory, so an existing bundle
622    // has to move out of the way first. Move it aside rather than deleting it:
623    // if publication then fails, deleting first would have left no bundle at
624    // all, which is worse than the stale one the caller started with.
625    let retired = match std::fs::symlink_metadata(destination) {
626        Ok(_) => {
627            let holder = tempfile::Builder::new()
628                .prefix(".mbx-export-retired-")
629                .tempdir_in(parent)?;
630            let moved = holder.path().join("bundle");
631            std::fs::rename(destination, &moved)
632                .wrap_err_with(|| format!("failed to replace {}", destination.display()))?;
633            Some((holder, moved))
634        }
635        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
636        Err(error) => {
637            return Err(error)
638                .wrap_err_with(|| format!("failed to replace {}", destination.display()));
639        }
640    };
641    if let Err(error) = std::fs::rename(&staged, destination) {
642        let _ = std::fs::remove_dir_all(&staged);
643        if let Some((holder, moved)) = retired
644            && std::fs::rename(&moved, destination).is_err()
645        {
646            // Putting the old bundle back has failed too, and letting the
647            // holder drop here would delete the only remaining copy of it.
648            // Keep it instead, and say where it went: a stale bundle the
649            // caller has to move back by hand still beats no bundle at all.
650            let kept = holder.keep();
651            log::warn!(
652                "could not restore {} after a failed export; its previous contents are at {}",
653                destination.display(),
654                kept.join("bundle").display()
655            );
656        }
657        return Err(error).wrap_err_with(|| format!("failed to publish {}", destination.display()));
658    }
659    // The replaced bundle is only discarded once the new one is in place.
660    drop(retired);
661    Ok(bytes)
662}
663
664/// Validate a cache export in isolation, then publish its objects and actions.
665pub fn import_archive(store: &Path, archive: &Path) -> Result<TransferOutcome> {
666    Ok(import_archive_with_attachments(store, archive)?.transfer)
667}
668
669/// Import a cache archive and return its named higher-level CAS roots.
670pub fn import_archive_with_attachments(store: &Path, archive: &Path) -> Result<ImportOutcome> {
671    // A directory bundle is read where it lies. Unpacking one into staging
672    // would write every byte a second time, which is the whole cost the
673    // directory form exists to avoid.
674    let staged;
675    let _staging_lock;
676    let root = if archive.is_dir() {
677        _staging_lock = None;
678        validate_directory_bundle(archive)?;
679        archive
680    } else {
681        // Stage inside the store, not the system temp directory. Publication
682        // moves the verified files with `fs::rename`, which only works within
683        // one filesystem; a `$TMPDIR` on another device silently downgrades
684        // every object to a copy and a second hash, and unpacking a
685        // multi-gigabyte export into a container's `/tmp` can run out of room.
686        let staging_root = store.join(IMPORT_STAGING_DIR);
687        std::fs::create_dir_all(&staging_root)?;
688        staged = tempfile::Builder::new()
689            .prefix("import-")
690            .tempdir_in(&staging_root)?;
691        // Held for the rest of the import so a concurrent sweep can tell this
692        // tree from one a killed process abandoned. The gap between creating
693        // the directory and taking the lock needs no closing: a sweep only
694        // considers trees a day old, and this one is new.
695        _staging_lock = Some(lock_import_staging(staged.path())?);
696        unpack_archive(archive, staged.path())?;
697        staged.path()
698    };
699    // Measure a directory bundle now: publication moves its objects into the
700    // store, so by the end there is nothing left to measure.
701    let bundle_bytes = archive.is_dir().then(|| tree_bytes(archive));
702    let manifest: ExportManifest =
703        serde_json::from_slice(&std::fs::read(root.join(EXPORT_MANIFEST))?)?;
704    let actions = manifest.actions.iter().cloned().collect::<BTreeSet<_>>();
705    let task_identities = manifest
706        .tasks
707        .iter()
708        .map(|task| task.task.as_str())
709        .collect::<BTreeSet<_>>();
710    if !matches!(manifest.version, LEGACY_EXPORT_VERSION | EXPORT_VERSION)
711        || (manifest.version == LEGACY_EXPORT_VERSION
712            && (!manifest.attachments.is_empty() || !manifest.objects.is_empty()))
713        || manifest.tasks.is_empty()
714        || actions.len() != manifest.actions.len()
715        || task_identities.len() != manifest.tasks.len()
716        || manifest.tasks.iter().any(|task| !task.validate())
717        || manifest
718            .actions
719            .iter()
720            .any(|action| action.algorithm != "blake3" || action.validate().is_err())
721        || manifest.tasks.iter().any(|task| {
722            task.predictions
723                .iter()
724                .any(|prediction| !actions.contains(&prediction.action))
725        })
726        || manifest
727            .attachments
728            .keys()
729            .any(|name| !valid_attachment_name(name))
730        || manifest.attachments.values().any(|digest| {
731            digest.algorithm != "blake3"
732                || digest.validate().is_err()
733                || !manifest.objects.contains(digest)
734        })
735        || manifest.objects.iter().collect::<BTreeSet<_>>().len() != manifest.objects.len()
736        || manifest
737            .objects
738            .iter()
739            .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
740    {
741        eyre::bail!("unsupported or invalid cache export manifest");
742    }
743    let mut closure =
744        strict_closure(root, &actions).wrap_err("cache export is incomplete or corrupt")?;
745    let staged_cas = LocalCas::new(root);
746    for digest in &manifest.objects {
747        require_object(&staged_cas, &mut closure, digest)
748            .wrap_err("cache export attachment is incomplete or corrupt")?;
749    }
750    verify_pending(&mut closure.pending).wrap_err("cache export is incomplete or corrupt")?;
751
752    // Read every action result before anything is published. Publication moves
753    // a directory bundle's objects into the store, and a bundle cannot be
754    // re-imported once that has happened, so every failure that can be moved
755    // ahead of it should be: a malformed result has to fail while the bundle
756    // is still whole. What remains after this point is filesystem failure on
757    // the store itself, which a retry would not survive either.
758    let results = closure
759        .results
760        .iter()
761        .map(|path| {
762            let result: RemoteActionResult = serde_json::from_slice(&std::fs::read(path)?)
763                .wrap_err_with(|| format!("action result is invalid: {}", path.display()))?;
764            Ok(result)
765        })
766        .collect::<Result<Vec<_>>>()
767        .wrap_err("cache export is incomplete or corrupt")?;
768
769    let cas = LocalCas::new(store);
770    for path in &closure.objects {
771        let relative = path.strip_prefix(root)?;
772        let source = root.join(relative);
773        let digest = addressed_digest(root, &source, false)
774            .ok_or_else(|| eyre::eyre!("invalid cache object path {}", relative.display()))?;
775        // `strict_closure` verified every path in `objects` against its
776        // content-addressed name. Preserve that proof and move the owned
777        // staging file instead of hashing or copying the bytes again.
778        cas.adopt_verified_file(&digest, &source)?;
779    }
780    let action_cache = mbx_cache_core::LocalActionCache::new(store);
781    for result in &results {
782        action_cache.store(result)?;
783    }
784    for task in manifest.tasks {
785        merge_imported_manifest(store, task)?;
786    }
787    let bytes = match bundle_bytes {
788        Some(bytes) => {
789            // Publication moved the objects out of the bundle, so what is left
790            // is a shell of empty directories. Removing it keeps a restored
791            // bundle out of the job's disk budget for the rest of the run.
792            std::fs::remove_dir_all(archive)
793                .wrap_err_with(|| format!("failed to remove {}", archive.display()))?;
794            bytes
795        }
796        None => std::fs::metadata(archive)?.len(),
797    };
798    Ok(ImportOutcome {
799        transfer: TransferOutcome {
800            actions: actions.len() as u64,
801            objects: closure.objects.len() as u64,
802            bytes,
803        },
804        attachments: manifest.attachments,
805    })
806}
807
808fn validate_export_additions(additions: &ExportAdditions) -> Result<()> {
809    if additions
810        .attachments
811        .keys()
812        .any(|name| !valid_attachment_name(name))
813        || additions
814            .attachments
815            .values()
816            .any(|digest| !additions.objects.contains(digest))
817        || additions
818            .objects
819            .iter()
820            .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
821    {
822        eyre::bail!("invalid cache export attachment");
823    }
824    Ok(())
825}
826
827fn valid_attachment_name(name: &str) -> bool {
828    !name.is_empty()
829        && name.len() <= 128
830        && name
831            .bytes()
832            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
833}
834
835/// Everything one closure walk reached, and the leaves still to be hashed.
836#[derive(Default)]
837struct Closure {
838    objects: BTreeSet<PathBuf>,
839    results: BTreeSet<PathBuf>,
840    pending: Vec<PendingObject>,
841}
842
843/// A leaf object recorded by the walk and awaiting content verification.
844struct PendingObject {
845    digest: CacheDigest,
846    path: PathBuf,
847}
848
849fn strict_closure(store: &Path, actions: &BTreeSet<CacheDigest>) -> Result<Closure> {
850    let cas = LocalCas::new(store);
851    let action_cache = mbx_cache_core::LocalActionCache::new(store);
852    let mut closure = Closure::default();
853    let mut directories = BTreeSet::new();
854    for action in actions {
855        let result = action_cache
856            .find(action)?
857            .ok_or_else(|| eyre::eyre!("action result is missing for {}", action.hash))?;
858        closure.results.insert(action_cache.path_for(action)?);
859        require_object(&cas, &mut closure, &result.action)?;
860        if let Some(metadata) = &result.metadata {
861            let path = require_parsed_object(&cas, &mut closure, metadata)?;
862            let captured: CapturedOutput = serde_json::from_slice(&std::fs::read(path)?)
863                .wrap_err("action metadata is invalid")?;
864            require_object(&cas, &mut closure, &captured.stdout)?;
865            require_object(&cas, &mut closure, &captured.stderr)?;
866        }
867        if let Some(root) = &result.output_root {
868            let mut nodes = vec![root.clone()];
869            while let Some(digest) = nodes.pop() {
870                if !directories.insert(digest.clone()) {
871                    continue;
872                }
873                let path = require_parsed_object(&cas, &mut closure, &digest)?;
874                let directory: CacheDirectory = serde_json::from_slice(&std::fs::read(path)?)
875                    .wrap_err("output directory is invalid")?;
876                for file in directory.files {
877                    require_object(&cas, &mut closure, &file.digest)?;
878                }
879                for child in directory.directories {
880                    nodes.push(child.digest);
881                }
882            }
883        }
884    }
885    Ok(closure)
886}
887
888/// Verify every recorded leaf's contents, spread across the machine.
889///
890/// Callers must run this before publishing anything the walk reached: it is
891/// the step that proves the bytes match their content-addressed names, and
892/// both the exporter and the importer depend on that proof. A closure of a
893/// few thousand compiler outputs is several gigabytes read once, and the
894/// hash, not the read, is what saturates a core.
895fn verify_pending(pending: &mut [PendingObject]) -> Result<()> {
896    if pending.is_empty() {
897        return Ok(());
898    }
899    // Longest first. Handing out a half-gigabyte rlib last would leave one
900    // worker hashing it alone after the others have finished, and the lengths
901    // cost nothing: the walk already checked each one against its digest.
902    pending.sort_by(|left, right| {
903        right
904            .digest
905            .size
906            .cmp(&left.digest.size)
907            .then_with(|| left.path.cmp(&right.path))
908    });
909    let workers = std::thread::available_parallelism()
910        .map(usize::from)
911        .unwrap_or(1)
912        .min(pending.len());
913    let next = AtomicUsize::new(0);
914    // Report the lowest failing path rather than whichever worker happened to
915    // lose the race, so a corrupt closure names the same object every run.
916    let failure: Mutex<Option<(PathBuf, eyre::Report)>> = Mutex::new(None);
917    let pending: &[PendingObject] = pending;
918    std::thread::scope(|scope| {
919        for _ in 0..workers {
920            scope.spawn(|| {
921                loop {
922                    let index = next.fetch_add(1, Ordering::Relaxed);
923                    let Some(entry) = pending.get(index) else {
924                        break;
925                    };
926                    let error = match entry.digest.matches_file(&entry.path) {
927                        Ok(true) => continue,
928                        Ok(false) => eyre::eyre!(
929                            "local CAS blob failed digest verification: {}",
930                            entry.path.display()
931                        ),
932                        Err(error) => {
933                            error.wrap_err(format!("failed to verify {}", entry.path.display()))
934                        }
935                    };
936                    let mut failure = failure.lock().unwrap_or_else(|error| error.into_inner());
937                    if failure.as_ref().is_none_or(|(path, _)| entry.path < *path) {
938                        *failure = Some((entry.path.clone(), error));
939                    }
940                }
941            });
942        }
943    });
944    match failure
945        .into_inner()
946        .unwrap_or_else(|error| error.into_inner())
947    {
948        Some((_, error)) => Err(error),
949        None => Ok(()),
950    }
951}
952
953#[derive(Deserialize)]
954struct CapturedOutput {
955    stdout: CacheDigest,
956    stderr: CacheDigest,
957}
958
959fn merge_imported_manifest(destination: &Path, mut imported: TaskActionManifest) -> Result<()> {
960    let identity = imported.task.clone();
961    let destination_path = task_manifest_path(destination, &identity);
962    let lock_path = task_manifest_lock_path(destination, &identity);
963    std::fs::create_dir_all(lock_path.parent().expect("task manifest lock has a parent"))?;
964    let mut lock = fslock::LockFile::open(&lock_path)?;
965    lock.lock()?;
966    if let Ok(bytes) = std::fs::read(&destination_path)
967        && let Ok(existing) = serde_json::from_slice::<TaskActionManifest>(&bytes)
968        && existing.task == identity
969        && existing.validate()
970    {
971        let existing_predictions: BTreeMap<_, _> = existing
972            .predictions
973            .iter()
974            .cloned()
975            .map(|prediction| (prediction.invocation.clone(), prediction))
976            .collect();
977        // The bundle determines recency, but a checkout's current value wins
978        // when both manifests predict the same invocation.
979        let updates = imported
980            .predictions
981            .into_iter()
982            .map(|prediction| {
983                existing_predictions
984                    .get(&prediction.invocation)
985                    .cloned()
986                    .unwrap_or(prediction)
987            })
988            .collect();
989        imported.predictions =
990            merge_task_action_predictions(existing.predictions, updates, &BTreeSet::new())?;
991    }
992    write_atomic(&destination_path, &serde_json::to_vec(&imported)?)
993}
994
995/// Record a leaf object, deferring its content hash to `verify_pending`.
996///
997/// Presence and declared length are settled here, while the walk can still say
998/// cheaply which object is wrong. The contents are left for the parallel pass
999/// because leaves are where the bytes are: compiler outputs, captured streams,
1000/// and attachments, none of which steer the walk.
1001fn require_object(cas: &LocalCas, closure: &mut Closure, digest: &CacheDigest) -> Result<()> {
1002    let expected = cas.path_for(digest)?;
1003    if !closure.objects.insert(expected.clone()) {
1004        return Ok(());
1005    }
1006    let metadata = match std::fs::metadata(&expected) {
1007        Ok(metadata) => metadata,
1008        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1009            eyre::bail!("cache object is missing for {}", digest.hash)
1010        }
1011        Err(error) => {
1012            return Err(error).wrap_err_with(|| format!("failed to read {}", expected.display()));
1013        }
1014    };
1015    if metadata.len() != digest.size {
1016        eyre::bail!(
1017            "local CAS blob failed digest verification: {}",
1018            expected.display()
1019        );
1020    }
1021    closure.pending.push(PendingObject {
1022        digest: digest.clone(),
1023        path: expected,
1024    });
1025    Ok(())
1026}
1027
1028/// Verify an object the walk is about to deserialize, and return its path.
1029///
1030/// Action metadata and directory nodes decide what the walk visits next, so a
1031/// forged one steers the walk itself and has to be trusted before `serde_json`
1032/// sees it. They are canonical JSON and small, so hashing them in line costs
1033/// little next to the leaves they point at.
1034fn require_parsed_object(
1035    cas: &LocalCas,
1036    closure: &mut Closure,
1037    digest: &CacheDigest,
1038) -> Result<PathBuf> {
1039    let path = cas
1040        .find(digest)?
1041        .ok_or_else(|| eyre::eyre!("cache object is missing for {}", digest.hash))?;
1042    closure.objects.insert(path.clone());
1043    Ok(path)
1044}
1045
1046fn task_manifest_path(store: &Path, identity: &str) -> PathBuf {
1047    store
1048        .join("task-manifests/v1")
1049        .join(format!("{identity}.json"))
1050}
1051
1052fn task_manifest_lock_path(store: &Path, identity: &str) -> PathBuf {
1053    store
1054        .join("task-manifests/v1/locks")
1055        .join(format!("{identity}.lock"))
1056}
1057
1058fn append_file(builder: &mut tar::Builder<std::fs::File>, root: &Path, path: &Path) -> Result<()> {
1059    let name = path.strip_prefix(root)?;
1060    builder.append_path_with_name(path, name)?;
1061    Ok(())
1062}
1063
1064fn append_bytes(
1065    builder: &mut tar::Builder<std::fs::File>,
1066    name: &Path,
1067    bytes: &[u8],
1068) -> Result<()> {
1069    let mut header = tar::Header::new_gnu();
1070    header.set_size(bytes.len() as u64);
1071    header.set_mode(0o644);
1072    header.set_cksum();
1073    builder.append_data(&mut header, name, bytes)?;
1074    Ok(())
1075}
1076
1077/// Unpack a tar export into an empty staging directory.
1078fn unpack_archive(archive: &Path, staging: &Path) -> Result<()> {
1079    let file = std::fs::File::open(archive)
1080        .wrap_err_with(|| format!("failed to open {}", archive.display()))?;
1081    let mut bundle = tar::Archive::new(file);
1082    let mut seen = BTreeSet::new();
1083    for entry in bundle.entries()? {
1084        let mut entry = entry?;
1085        let entry_type = entry.header().entry_type();
1086        if !entry_type.is_file() && !entry_type.is_gnu_sparse() {
1087            eyre::bail!("cache export contains a non-file entry");
1088        }
1089        let path = entry.path()?.into_owned();
1090        validate_archive_path(&path)?;
1091        if !seen.insert(path.clone()) {
1092            eyre::bail!("cache export contains duplicate entry {}", path.display());
1093        }
1094        let destination = staging.join(&path);
1095        std::fs::create_dir_all(destination.parent().expect("entry has a parent"))?;
1096        // `unpack` understands GNU sparse maps. A plain stream copy expands
1097        // holes into physical zeroes, which is both slower and much larger for
1098        // Rust artifacts containing sparse sections.
1099        entry.unpack(&destination)?;
1100    }
1101    Ok(())
1102}
1103
1104/// Check a directory bundle against the policy a tar bundle is held to.
1105///
1106/// A tar carries its own entry types, so the importer can refuse a symlink or
1107/// a device node by reading the header. A directory has to be walked for the
1108/// same answer, and `DirEntry::metadata` does not follow symlinks, so anything
1109/// that is not a plain file or a directory is rejected here rather than
1110/// followed out of the bundle.
1111fn validate_directory_bundle(root: &Path) -> Result<()> {
1112    let mut pending = vec![root.to_path_buf()];
1113    while let Some(directory) = pending.pop() {
1114        for entry in std::fs::read_dir(&directory)
1115            .wrap_err_with(|| format!("failed to read {}", directory.display()))?
1116        {
1117            let entry = entry?;
1118            let metadata = entry.metadata()?;
1119            let path = entry.path();
1120            if metadata.is_dir() {
1121                pending.push(path);
1122                continue;
1123            }
1124            if !metadata.is_file() {
1125                eyre::bail!("cache export contains a non-file entry");
1126            }
1127            validate_archive_path(path.strip_prefix(root)?)?;
1128            reject_linked_file(&metadata, &path)?;
1129        }
1130    }
1131    Ok(())
1132}
1133
1134/// Refuse a bundle file that shares its inode with a name outside the bundle.
1135///
1136/// Import adopts objects by moving them into the CAS. A second hard link would
1137/// survive that move and keep write access to a blob the store then treats as
1138/// verified and immutable, which is a way to change a verified object after it
1139/// has been checked.
1140#[cfg(unix)]
1141fn reject_linked_file(metadata: &std::fs::Metadata, path: &Path) -> Result<()> {
1142    use std::os::unix::fs::MetadataExt as _;
1143
1144    if metadata.nlink() > 1 {
1145        eyre::bail!("cache export contains a hard link at {}", path.display());
1146    }
1147    Ok(())
1148}
1149
1150/// Windows reports a link count only through `MetadataExt::number_of_links`,
1151/// which is unstable, so this check cannot be made there without reaching for
1152/// the platform API directly. The gap is narrow -- it needs an attacker who
1153/// can already write into the job's filesystem before the import runs -- but
1154/// it is a gap, and a Windows directory bundle does not get this protection.
1155#[cfg(not(unix))]
1156fn reject_linked_file(_metadata: &std::fs::Metadata, _path: &Path) -> Result<()> {
1157    Ok(())
1158}
1159
1160fn validate_archive_path(path: &Path) -> Result<()> {
1161    if path.as_os_str().is_empty()
1162        || path.is_absolute()
1163        || path
1164            .components()
1165            .any(|component| !matches!(component, Component::Normal(_)))
1166    {
1167        eyre::bail!("cache export contains unsafe path {}", path.display());
1168    }
1169    // Compare components rather than a spelling. A tar entry always writes its
1170    // path with forward slashes, but a directory bundle is walked with the
1171    // platform's separator, so on Windows a prefix match against "cas/v1/"
1172    // would reject every file in the bundle.
1173    let parts = path
1174        .components()
1175        .map(|component| component.as_os_str().to_string_lossy().into_owned())
1176        .collect::<Vec<_>>();
1177    let expected = match parts.as_slice() {
1178        [name] => name == EXPORT_MANIFEST,
1179        [root, version, _rest @ ..] => {
1180            parts.len() > 2 && (root == "cas" || root == "action-results") && version == "v1"
1181        }
1182        [] => false,
1183    };
1184    if !expected {
1185        eyre::bail!("cache export contains unexpected path {}", path.display());
1186    }
1187    Ok(())
1188}
1189
1190/// One checkout's claim on the actions a build identity recorded.
1191///
1192/// The target directory is not read back by anything yet; it is recorded
1193/// because it is the other half of what the shim mapped, and a record that
1194/// names only half of it would have to be rewritten to answer where the
1195/// artifacts went.
1196#[derive(Debug, Clone, Serialize, Deserialize)]
1197#[serde(deny_unknown_fields)]
1198struct CheckoutRecord {
1199    version: u8,
1200    workspace_root: PathBuf,
1201    target_dir: PathBuf,
1202    updated_secs: u64,
1203}
1204
1205/// Summarize what the store currently holds.
1206pub fn stats(store: &Path) -> Result<StoreStats> {
1207    let objects = walk_files(&store.join(CAS_DIR))?;
1208    let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
1209    let checkouts = scan_checkouts(store)?;
1210    Ok(StoreStats {
1211        objects: objects.len() as u64,
1212        object_bytes: objects.iter().map(|entry| entry.size).sum(),
1213        action_results: results.len() as u64,
1214        action_result_bytes: results.iter().map(|entry| entry.size).sum(),
1215        live_checkouts: checkouts.live_records,
1216        stale_checkouts: checkouts.stale_records.len() as u64,
1217    })
1218}
1219
1220/// Size the target tree a checkout record names, if it names one.
1221///
1222/// A build with no target directory of its own -- one driven by make or CMake,
1223/// which writes wherever it was told -- records the checkout in that field
1224/// rather than inventing a directory. Walking it would report the whole source
1225/// tree, `.git` included, as though it were build output.
1226fn checkout_target_bytes(sizes: &mut BTreeMap<PathBuf, u64>, record: &CheckoutRecord) -> u64 {
1227    if record.target_dir == record.workspace_root {
1228        return 0;
1229    }
1230    cached_tree_bytes(sizes, &record.target_dir)
1231}
1232
1233/// Attribute cache and target bytes to each recorded workspace.
1234///
1235/// Shared objects are counted for every workspace that can reach them. That
1236/// makes each row answer "how much keeps this workspace warm" without
1237/// pretending shared storage can be divided exactly between projects.
1238pub fn projects(store: &Path) -> Result<Vec<ProjectUsage>> {
1239    project_usage(store, true)
1240}
1241
1242/// The reusable cache bytes of each live workspace, as [`projects`] counts
1243/// them.
1244///
1245/// Sizing target directories is most of what [`projects`] costs on a machine
1246/// with many checkouts, and an estimate of cache sharing never looks at them.
1247pub fn live_project_cache_bytes(store: &Path) -> Result<Vec<u64>> {
1248    Ok(project_usage(store, false)?
1249        .into_iter()
1250        .filter(|project| project.live)
1251        .map(|project| project.action_bytes)
1252        .collect())
1253}
1254
1255fn project_usage(store: &Path, size_targets: bool) -> Result<Vec<ProjectUsage>> {
1256    let mut projects: BTreeMap<PathBuf, (BTreeSet<String>, bool, u64)> = BTreeMap::new();
1257    let mut target_sizes = BTreeMap::new();
1258    let root = store.join(CHECKOUTS_DIR);
1259    for identity_entry in read_dir_or_empty(&root)? {
1260        let identity = identity_entry.file_name().to_string_lossy().into_owned();
1261        if !is_task_identity(&identity) || !identity_entry.file_type()?.is_dir() {
1262            continue;
1263        }
1264        for entry in walk_files(&identity_entry.path())? {
1265            let Some(record) = read_checkout_record(&entry.path) else {
1266                continue;
1267            };
1268            let project = projects.entry(record.workspace_root.clone()).or_default();
1269            if claim_is_live(store, &record) {
1270                project.0.insert(identity.clone());
1271                project.1 = true;
1272            }
1273            if size_targets {
1274                project.2 = project
1275                    .2
1276                    .max(checkout_target_bytes(&mut target_sizes, &record));
1277            }
1278        }
1279    }
1280    let live = projects
1281        .values()
1282        .flat_map(|(identities, _, _)| identities.iter().cloned())
1283        .collect::<BTreeSet<_>>();
1284    let mut reachability = Reachability::new(store);
1285    resolve_identities(&reachability, &live);
1286    let mut usages = Vec::new();
1287    for (workspace_root, (identities, live, target_bytes)) in projects {
1288        let action_bytes = reachability.bytes(&identities);
1289        usages.push(ProjectUsage {
1290            workspace_root,
1291            identities: identities.len() as u64,
1292            action_bytes,
1293            target_bytes,
1294            live,
1295        });
1296    }
1297    usages.sort_by(|left, right| {
1298        right
1299            .action_bytes
1300            .saturating_add(right.target_bytes)
1301            .cmp(&left.action_bytes.saturating_add(left.target_bytes))
1302            .then_with(|| left.workspace_root.cmp(&right.workspace_root))
1303    });
1304    Ok(usages)
1305}
1306
1307/// Resolve every identity's recorded actions into `reachability`.
1308///
1309/// Manifests are the one large input: a busy checkout's runs to tens of
1310/// megabytes of JSON, and parsing hundreds of them one after another is most
1311/// of what sizing every workspace costs. Resolving what they reach is mostly
1312/// cold file reads. Both are spread over a few threads, each taking whole
1313/// identities from a shared counter.
1314fn resolve_identities(reachability: &Reachability, identities: &BTreeSet<String>) {
1315    const MAX_WORKERS: usize = 8;
1316    let identities = identities.iter().collect::<Vec<_>>();
1317    let workers = std::thread::available_parallelism()
1318        .map(usize::from)
1319        .unwrap_or(1)
1320        .min(MAX_WORKERS)
1321        .min(identities.len());
1322    let next = AtomicUsize::new(0);
1323    std::thread::scope(|scope| {
1324        for _ in 0..workers {
1325            scope.spawn(|| {
1326                while let Some(identity) = identities.get(next.fetch_add(1, Ordering::Relaxed)) {
1327                    // One manifest this build cannot read is not worth
1328                    // abandoning the report over. It reaches nothing, which
1329                    // can only understate sharing.
1330                    let actions = task_manifest_actions(&reachability.store, identity)
1331                        .unwrap_or_else(|error| {
1332                            log::debug!("could not read the manifest for {identity}: {error}");
1333                            Vec::new()
1334                        });
1335                    reachability.record(identity, &actions);
1336                }
1337            });
1338        }
1339    });
1340}
1341
1342/// The store paths each identity can reach, resolved once for every workspace.
1343///
1344/// Workspaces share identities, identities share most of their actions, and
1345/// actions share output trees. Answering each workspace from scratch reread
1346/// the same manifests, results, and trees, and stat-ed the same objects, once
1347/// per workspace, which on a machine with a couple of hundred checkouts was
1348/// most of the half minute `mbx stats` took. Here every file is read and
1349/// stat-ed at most once, and a workspace's total is a union of indices already
1350/// in memory.
1351///
1352/// What is reachable matches [`rooted_objects`] plus the action-result records
1353/// themselves, and every read is as tolerant as it is there.
1354struct Reachability {
1355    store: PathBuf,
1356    cas: LocalCas,
1357    action_cache: mbx_cache_core::LocalActionCache,
1358    /// Every indexed path and its size, zero if it no longer exists.
1359    paths: Mutex<PathIndex>,
1360    identities: Mutex<HashMap<String, ActionPaths>>,
1361    actions: Mutex<HashMap<CacheDigest, Arc<[usize]>>>,
1362    /// Each tree blob's child directories and the paths it names directly.
1363    directories: Mutex<HashMap<CacheDigest, Option<DirectoryPaths>>>,
1364    /// The workspace number that last counted each path.
1365    counted: Vec<usize>,
1366    workspaces: usize,
1367}
1368
1369#[derive(Default)]
1370struct PathIndex {
1371    indices: HashMap<PathBuf, usize>,
1372    sizes: Vec<u64>,
1373}
1374
1375/// The paths each of an identity's actions reaches.
1376type ActionPaths = Arc<[Arc<[usize]>]>;
1377
1378type DirectoryPaths = (Arc<[CacheDigest]>, Arc<[usize]>);
1379
1380/// The caches are only guards around maps; a panicked holder leaves them
1381/// consistent, so poisoning is not worth failing a report over.
1382fn locked<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
1383    mutex.lock().unwrap_or_else(|error| error.into_inner())
1384}
1385
1386impl Reachability {
1387    fn new(store: &Path) -> Self {
1388        Self {
1389            store: store.to_path_buf(),
1390            cas: LocalCas::new(store),
1391            action_cache: mbx_cache_core::LocalActionCache::new(store),
1392            paths: Mutex::default(),
1393            identities: Mutex::default(),
1394            actions: Mutex::default(),
1395            directories: Mutex::default(),
1396            counted: Vec::new(),
1397            workspaces: 0,
1398        }
1399    }
1400
1401    /// Bytes reachable from any of `identities`, each path counted once.
1402    ///
1403    /// An identity that was never recorded reaches nothing.
1404    fn bytes(&mut self, identities: &BTreeSet<String>) -> u64 {
1405        let reachable = {
1406            let recorded = locked(&self.identities);
1407            identities
1408                .iter()
1409                .filter_map(|identity| recorded.get(identity).cloned())
1410                .collect::<Vec<_>>()
1411        };
1412        let sizes = std::mem::take(&mut locked(&self.paths).sizes);
1413        self.workspaces += 1;
1414        self.counted.resize(sizes.len(), 0);
1415        let mut bytes = 0_u64;
1416        for index in reachable
1417            .iter()
1418            .flat_map(|actions| actions.iter())
1419            .flat_map(|paths| paths.iter())
1420        {
1421            if self.counted[*index] != self.workspaces {
1422                self.counted[*index] = self.workspaces;
1423                bytes = bytes.saturating_add(sizes[*index]);
1424            }
1425        }
1426        locked(&self.paths).sizes = sizes;
1427        bytes
1428    }
1429
1430    /// Resolve what one identity's recorded actions reach.
1431    ///
1432    /// Safe to call from several threads. Two threads may resolve the same
1433    /// action at once; they read the same files and reach the same answer.
1434    fn record(&self, identity: &str, actions: &[CacheDigest]) {
1435        let actions = actions
1436            .iter()
1437            .map(|action| self.action(action))
1438            .collect::<Arc<[_]>>();
1439        locked(&self.identities).insert(identity.to_string(), actions);
1440    }
1441
1442    fn action(&self, action: &CacheDigest) -> Arc<[usize]> {
1443        if let Some(paths) = locked(&self.actions).get(action) {
1444            return paths.clone();
1445        }
1446        let mut paths = Vec::new();
1447        if let Ok(path) = self.action_cache.path_for(action) {
1448            paths.push(self.index(path));
1449        }
1450        if let Some(result) = read_action_result(&self.store, action) {
1451            self.push_digest(&mut paths, &result.action);
1452            if let Some(metadata) = &result.metadata {
1453                self.push_digest(&mut paths, metadata);
1454                if let Some(rustc) = read_rustc_metadata(&self.cas, metadata) {
1455                    self.push_digest(&mut paths, &rustc.stdout);
1456                    self.push_digest(&mut paths, &rustc.stderr);
1457                }
1458            }
1459            if let Some(output_root) = &result.output_root {
1460                self.push_digest(&mut paths, output_root);
1461                let mut pending = vec![output_root.clone()];
1462                let mut visited = HashSet::new();
1463                while let Some(digest) = pending.pop() {
1464                    if !visited.insert(digest.clone()) {
1465                        continue;
1466                    }
1467                    let Some((children, named)) = self.directory(&digest) else {
1468                        continue;
1469                    };
1470                    paths.extend(named.iter().copied());
1471                    pending.extend(children.iter().cloned());
1472                }
1473            }
1474        }
1475        paths.sort_unstable();
1476        paths.dedup();
1477        let paths = Arc::<[usize]>::from(paths);
1478        locked(&self.actions).insert(action.clone(), paths.clone());
1479        paths
1480    }
1481
1482    /// The child directories of a tree blob and the paths it names directly.
1483    fn directory(&self, digest: &CacheDigest) -> Option<DirectoryPaths> {
1484        if let Some(entry) = locked(&self.directories).get(digest) {
1485            return entry.clone();
1486        }
1487        let entry = read_directory(&self.cas, digest).map(|directory| {
1488            let mut paths = Vec::new();
1489            for file in &directory.files {
1490                self.push_digest(&mut paths, &file.digest);
1491            }
1492            for child in &directory.directories {
1493                self.push_digest(&mut paths, &child.digest);
1494            }
1495            let children = directory
1496                .directories
1497                .into_iter()
1498                .map(|child| child.digest)
1499                .collect::<Arc<[_]>>();
1500            (children, Arc::<[usize]>::from(paths))
1501        });
1502        locked(&self.directories).insert(digest.clone(), entry.clone());
1503        entry
1504    }
1505
1506    fn push_digest(&self, paths: &mut Vec<usize>, digest: &CacheDigest) {
1507        if let Ok(path) = self.cas.path_for(digest) {
1508            paths.push(self.index(path));
1509        }
1510    }
1511
1512    fn index(&self, path: PathBuf) -> usize {
1513        if let Some(index) = locked(&self.paths).indices.get(&path) {
1514            return *index;
1515        }
1516        // The stat runs outside the lock. A second thread may stat the same
1517        // path; whichever registers first wins and both get one index.
1518        let size = std::fs::metadata(&path).map_or(0, |metadata| metadata.len());
1519        let mut paths = locked(&self.paths);
1520        if let Some(index) = paths.indices.get(&path) {
1521            return *index;
1522        }
1523        let index = paths.sizes.len();
1524        paths.sizes.push(size);
1525        paths.indices.insert(path, index);
1526        index
1527    }
1528}
1529
1530/// Return the largest blobs and action-result records in descending order.
1531pub fn largest(store: &Path, limit: usize) -> Result<Vec<LargestEntry>> {
1532    let mut entries = Vec::new();
1533    for (kind, root, action_result) in [
1534        ("object", store.join(CAS_DIR), false),
1535        ("action result", store.join(ACTION_RESULTS_DIR), true),
1536    ] {
1537        entries.extend(
1538            walk_files(&root)?
1539                .into_iter()
1540                .filter(|entry| addressed_digest(store, &entry.path, action_result).is_some())
1541                .map(|entry| LargestEntry {
1542                    kind,
1543                    path: entry.path,
1544                    bytes: entry.size,
1545                }),
1546        );
1547    }
1548    entries.sort_by(|left, right| {
1549        right
1550            .bytes
1551            .cmp(&left.bytes)
1552            .then_with(|| left.path.cmp(&right.path))
1553    });
1554    entries.truncate(limit);
1555    Ok(entries)
1556}
1557
1558/// Verify every addressable CAS object and action-result record.
1559pub fn verify(store: &Path) -> Result<VerifyOutcome> {
1560    let cas = LocalCas::new(store);
1561    let action_cache = mbx_cache_core::LocalActionCache::new(store);
1562    // Verification reports the store as it is on disk and removes nothing, so
1563    // no object is pending removal the way one is mid-sweep.
1564    let virtually_removed = HashSet::new();
1565    let mut outcome = VerifyOutcome::default();
1566    for entry in walk_files(&store.join(CAS_DIR))? {
1567        let Some(digest) = addressed_digest(store, &entry.path, false) else {
1568            continue;
1569        };
1570        outcome.checked_objects += 1;
1571        if !matches!(cas.find(&digest), Ok(Some(_))) {
1572            outcome.problems.push(entry.path);
1573        }
1574    }
1575    for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
1576        let Some(digest) = addressed_digest(store, &entry.path, true) else {
1577            continue;
1578        };
1579        outcome.checked_action_results += 1;
1580        if !matches!(action_cache.find(&digest), Ok(Some(_)))
1581            || action_result_is_dangling(&cas, &entry.path, &virtually_removed)?
1582        {
1583            outcome.problems.push(entry.path);
1584        }
1585    }
1586    outcome.problems.sort();
1587    Ok(outcome)
1588}
1589
1590/// Remove checkout claims belonging to exactly one workspace.
1591pub fn remove_project(store: &Path, workspace_root: &Path) -> Result<RemoveProjectOutcome> {
1592    let mut outcome = RemoveProjectOutcome::default();
1593    for entry in walk_files(&store.join(CHECKOUTS_DIR))? {
1594        if read_checkout_record(&entry.path)
1595            .is_some_and(|record| record.workspace_root == workspace_root)
1596        {
1597            std::fs::remove_file(&entry.path)?;
1598            outcome.removed_checkout_records += 1;
1599            if let Some(parent) = entry.path.parent() {
1600                let _ = std::fs::remove_dir(parent);
1601            }
1602        }
1603    }
1604    match std::fs::remove_file(latest_receipt_path(store, workspace_root)) {
1605        Ok(()) => {}
1606        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1607        Err(error) => return Err(error.into()),
1608    }
1609    Ok(outcome)
1610}
1611
1612/// Record that `workspace_root` built `identity`.
1613///
1614/// Identities are shared by every checkout of one dependency graph, so this is
1615/// what later tells the collector whether anything still needs what a build
1616/// cached: one file per checkout under the identity it built. Writing a whole
1617/// file per checkout rather than merging a list into one keeps concurrent
1618/// builds out of each other's way -- there is nothing to merge, so there is no
1619/// lock and no lost update.
1620///
1621/// A build with no target directory of its own passes `workspace_root` for
1622/// `target_dir`, which reads as "none" rather than as a tree to measure.
1623pub fn record_checkout(
1624    store: &Path,
1625    identity: &str,
1626    workspace_root: &Path,
1627    target_dir: &Path,
1628) -> Result<()> {
1629    let record = CheckoutRecord {
1630        version: CHECKOUT_RECORD_VERSION,
1631        workspace_root: workspace_root.to_path_buf(),
1632        target_dir: target_dir.to_path_buf(),
1633        updated_secs: SystemTime::now()
1634            .duration_since(UNIX_EPOCH)
1635            .map(|since| since.as_secs())
1636            .unwrap_or_default(),
1637    };
1638    let mut contents = serde_json::to_vec(&record)?;
1639    contents.push(b'\n');
1640    // Refreshed by every build and read back only as a last-used stamp, so a
1641    // power cut costs one build's worth of recency, not a wait on the disk.
1642    write_advisory(
1643        &checkout_record_path(store, identity, workspace_root),
1644        &contents,
1645    )
1646}
1647
1648fn checkout_record_path(store: &Path, identity: &str, workspace_root: &Path) -> PathBuf {
1649    // The path is hashed rather than escaped: it is only ever compared against
1650    // the same hash of the same path, and every filesystem in play disagrees
1651    // about which characters a name may hold.
1652    let key = CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash;
1653    store
1654        .join(CHECKOUTS_DIR)
1655        .join(identity)
1656        .join(format!("{key}.json"))
1657}
1658
1659/// Evict objects until the store fits within `max_bytes`.
1660///
1661/// Objects no live checkout can reach are evicted first, so deleting a worktree
1662/// releases what only that worktree needed. Within each class eviction is
1663/// least-recently-used by access time where the filesystem records it, falling
1664/// back to modification time. A restore verifies the blob it serves, which
1665/// means it reads the whole file and the access time is real -- but only where
1666/// the mount records one, so the ordering is an approximation either way.
1667/// Evicting a live object costs a recompile, never correctness.
1668pub fn gc(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
1669    gc_with_mode(store, max_bytes, false)
1670}
1671
1672/// Describe the collection `gc` would perform without changing the store.
1673pub fn gc_dry_run(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
1674    gc_with_mode(store, max_bytes, true)
1675}
1676
1677fn gc_with_mode(store: &Path, max_bytes: u64, dry_run: bool) -> Result<GcOutcome> {
1678    let mut objects = walk_files(&store.join(CAS_DIR))?;
1679    let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
1680    let mut live_bytes = objects
1681        .iter()
1682        .chain(results.iter())
1683        .map(|entry| entry.size)
1684        .sum::<u64>();
1685
1686    let mut outcome = GcOutcome::default();
1687
1688    // Prune before deciding what is rooted, so a checkout deleted since the
1689    // last sweep stops protecting its artifacts during this one.
1690    let checkouts = scan_checkouts(store)?;
1691    for path in &checkouts.stale_records {
1692        if dry_run || matches!(remove(path)?, Removal::Removed) {
1693            outcome.removed_checkout_records += 1;
1694        }
1695        // The identity directory is left behind empty otherwise. It may hold
1696        // other checkouts, in which case this fails and that is the answer.
1697        if !dry_run && let Some(parent) = path.parent() {
1698            let _ = std::fs::remove_dir(parent);
1699        }
1700    }
1701
1702    let mut virtually_removed = HashSet::new();
1703    if live_bytes > max_bytes {
1704        // Reachability costs a read per action result and per output tree, so
1705        // it is computed only when something is actually about to be evicted.
1706        // Under budget a sweep stays a directory walk and nothing more.
1707        let mut rooted = rooted_objects(store, &checkouts.live_identities)?;
1708        // A grouped CI export deliberately retains the exact actions from
1709        // every command in the job. Later commands can replace the current
1710        // task-manifest prediction for the same invocation, but the earlier
1711        // receipt still has to remain exportable until the post-job step runs.
1712        // Receipt bookkeeping is not part of the cache budget, so consult it
1713        // only when a sweep is already necessary and root its object closure.
1714        rooted.extend(rooted_action_objects(
1715            store,
1716            grouped_receipt_actions(store)?,
1717        ));
1718        // `false` sorts first, so unrooted objects go before rooted ones and
1719        // each class goes oldest-first. A store with no records at all roots
1720        // nothing and this is exactly the LRU it was before.
1721        objects.sort_by_cached_key(|entry| (rooted.contains(&entry.path), entry.used));
1722        let evicted = evict_objects(&objects, &rooted, live_bytes, max_bytes, |path| {
1723            if dry_run {
1724                virtually_removed.insert(path.to_path_buf());
1725                Ok(Removal::Removed)
1726            } else {
1727                remove(path)
1728            }
1729        })?;
1730        live_bytes = evicted.remaining_bytes;
1731        outcome.removed_objects += evicted.removed_objects;
1732        outcome.removed_bytes += evicted.removed_bytes;
1733    }
1734
1735    // An action result whose objects are gone can only produce a miss, so drop
1736    // it rather than leave the index pointing at nothing. This runs whether or
1737    // not this call evicted anything, since another process may have, and a
1738    // store that is over budget on results alone still needs the sweep.
1739    let cas = LocalCas::new(store);
1740    for entry in &results {
1741        if action_result_is_dangling(&cas, &entry.path, &virtually_removed)? {
1742            match if dry_run {
1743                Removal::Removed
1744            } else {
1745                remove(&entry.path)?
1746            } {
1747                Removal::Removed => {
1748                    live_bytes = live_bytes.saturating_sub(entry.size);
1749                    outcome.removed_action_results += 1;
1750                    outcome.removed_bytes += entry.size;
1751                }
1752                Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
1753                Removal::Blocked => {}
1754            }
1755        }
1756    }
1757
1758    let sessions = prune_sessions(store, dry_run)?;
1759    outcome.removed_session_streams += sessions.removed_streams;
1760    outcome.removed_bytes += sessions.removed_bytes;
1761    prune_import_staging(store, dry_run);
1762
1763    outcome.remaining_bytes = live_bytes;
1764    Ok(outcome)
1765}
1766
1767/// Discard import staging trees that a killed process left behind.
1768///
1769/// A finished import removes its own tree, and a failed one unwinds through
1770/// `TempDir`, so only a kill leaks. What leaks is the whole expanded export,
1771/// and it sits outside `cas/v1` and `action-results/v1`, so the sweep's own
1772/// accounting cannot see it and nothing else would ever reclaim it.
1773///
1774/// Age is what makes this safe to do without a lock: a concurrent import
1775/// holds no claim this could consult, and one that has been extracting for a
1776/// day has worse problems than a stale directory. Failures are logged rather
1777/// than returned, because losing a staging tree must not fail a sweep.
1778fn prune_import_staging(store: &Path, dry_run: bool) {
1779    if dry_run {
1780        return;
1781    }
1782    let root = store.join(IMPORT_STAGING_DIR);
1783    let Ok(entries) = read_dir_or_empty(&root) else {
1784        return;
1785    };
1786    for entry in entries {
1787        let Ok(metadata) = entry.metadata() else {
1788            continue;
1789        };
1790        let abandoned = metadata
1791            .modified()
1792            .ok()
1793            .and_then(|modified| SystemTime::now().duration_since(modified).ok())
1794            .is_some_and(|age| age >= IMPORT_STAGING_RETENTION);
1795        if !abandoned {
1796            continue;
1797        }
1798        let path = entry.path();
1799        if !metadata.is_dir() {
1800            // A claim whose tree is already gone: reclaim it so claims cannot
1801            // pile up one per import that was killed after its tree went.
1802            if path
1803                .extension()
1804                .is_some_and(|extension| extension == "lock")
1805                && !claimed_staging_path(&path).exists()
1806            {
1807                let _ = std::fs::remove_file(&path);
1808            }
1809            continue;
1810        }
1811        // Age alone would be a guess. An import that outlives the retention
1812        // window -- a very large bundle on slow storage -- still holds its
1813        // lock, and taking its tree out from under it would fail a running
1814        // restore. A lock nobody holds is one the kernel released for a
1815        // process that is gone.
1816        let lock_path = import_staging_lock_path(&path);
1817        let Ok(mut lock) = fslock::LockFile::open(&lock_path) else {
1818            continue;
1819        };
1820        if !matches!(lock.try_lock(), Ok(true)) {
1821            continue;
1822        }
1823        if let Err(error) = std::fs::remove_dir_all(&path) {
1824            log::debug!(
1825                "could not remove abandoned import staging {}: {error}",
1826                path.display()
1827            );
1828            continue;
1829        }
1830        drop(lock);
1831        let _ = std::fs::remove_file(&lock_path);
1832    }
1833}
1834
1835/// The staging tree a claim describes.
1836fn claimed_staging_path(lock: &Path) -> PathBuf {
1837    lock.with_extension("")
1838}
1839
1840/// Where the liveness lock for one staging tree lives.
1841///
1842/// A sibling of the tree rather than a file inside it, so that removing the
1843/// tree does not remove the lock the remover is holding.
1844fn import_staging_lock_path(staging: &Path) -> PathBuf {
1845    let mut name = staging.as_os_str().to_os_string();
1846    name.push(".lock");
1847    PathBuf::from(name)
1848}
1849
1850/// One import's claim on its staging tree, released when the import ends.
1851///
1852/// The lock file is removed with the claim. `TempDir` takes the tree itself,
1853/// and a claim left behind would otherwise outlive every tree it described:
1854/// the sweep walks directories, so nothing would ever collect it.
1855struct StagingClaim {
1856    lock: Option<fslock::LockFile>,
1857    path: PathBuf,
1858}
1859
1860impl Drop for StagingClaim {
1861    fn drop(&mut self) {
1862        // Release before unlinking, so a sweep that already opened this file
1863        // sees an unlocked claim rather than waiting on one that is going away.
1864        drop(self.lock.take());
1865        let _ = std::fs::remove_file(&self.path);
1866    }
1867}
1868
1869/// Claim a staging tree for the lifetime of one import.
1870fn lock_import_staging(staging: &Path) -> Result<StagingClaim> {
1871    let path = import_staging_lock_path(staging);
1872    let mut lock = fslock::LockFile::open(&path)
1873        .wrap_err_with(|| format!("failed to open {}", path.display()))?;
1874    lock.lock()?;
1875    Ok(StagingClaim {
1876        lock: Some(lock),
1877        path,
1878    })
1879}
1880
1881#[derive(Debug, Default, PartialEq, Eq)]
1882struct SessionPrune {
1883    removed_streams: u64,
1884    removed_bytes: u64,
1885}
1886
1887fn prune_sessions(store: &Path, dry_run: bool) -> Result<SessionPrune> {
1888    let mut prune = SessionPrune::default();
1889    let ids = events::session_ids(store);
1890    let surplus = ids.len().saturating_sub(MAX_SESSIONS);
1891    for (index, id) in ids.iter().enumerate() {
1892        let paths = events::session_paths(store, id);
1893        let metadata = match std::fs::metadata(&paths.events) {
1894            Ok(metadata) => metadata,
1895            Err(_) => continue,
1896        };
1897        let stale = metadata
1898            .modified()
1899            .ok()
1900            .and_then(|at| SystemTime::now().duration_since(at).ok())
1901            .is_some_and(|age| age > SESSION_RETENTION);
1902        if index >= surplus && !stale {
1903            continue;
1904        }
1905        if events::session_is_live(store, id) {
1906            continue;
1907        }
1908        if dry_run {
1909            prune.removed_streams += 1;
1910            prune.removed_bytes += metadata.len();
1911            continue;
1912        }
1913        if matches!(remove(&paths.events)?, Removal::Removed) {
1914            prune.removed_streams += 1;
1915            prune.removed_bytes += metadata.len();
1916            let _ = std::fs::remove_file(&paths.lock);
1917        }
1918    }
1919    if !dry_run {
1920        for lock in events::orphaned_locks(store) {
1921            let _ = std::fs::remove_file(lock);
1922        }
1923    }
1924    Ok(prune)
1925}
1926
1927/// Sweep the store if `interval` has passed since the last attempt.
1928///
1929/// The stamp is written before the sweep, not after. Two builds finishing
1930/// together should cost one sweep between them, and a sweep that dies partway
1931/// should wait its turn like any other rather than retry on every build.
1932pub fn sweep_if_due(store: &Path, max_bytes: u64, interval: Duration) -> Result<Option<GcOutcome>> {
1933    if !claim_sweep(store, interval)? {
1934        return Ok(None);
1935    }
1936    gc(store, max_bytes).map(Some)
1937}
1938
1939/// Stamp a due sweep before its callers perform coordinated GC.
1940///
1941/// The sweep lock serializes claims, so updating the existing marker in place
1942/// is enough. In particular, it does not need a temporary file and rename:
1943/// claiming a sweep must still work when the disk has no room for one.
1944pub fn claim_sweep(store: &Path, interval: Duration) -> Result<bool> {
1945    let lock_path = store.join(SWEEP_LOCK);
1946    std::fs::create_dir_all(lock_path.parent().expect("sweep lock has a parent"))?;
1947    let mut lock = fslock::LockFile::open(&lock_path)?;
1948    lock.lock()?;
1949
1950    let stamp = store.join(SWEEP_STAMP);
1951    if sweep_stamp_is_fresh(&stamp, interval) {
1952        return Ok(false);
1953    }
1954    let stamp = std::fs::OpenOptions::new()
1955        .create(true)
1956        .write(true)
1957        .truncate(false)
1958        .open(&stamp)?;
1959    stamp.set_modified(SystemTime::now())?;
1960    Ok(true)
1961}
1962
1963/// Whether [`claim_sweep`] would stamp a sweep now.
1964///
1965/// A read of the stamp, no lock and no write: this answers a build that only
1966/// wants to know whether to start a collector, and may be answered yes to two
1967/// builds at once. The claim itself stays inside the collector, which is where
1968/// the lock and the stamp are, so of two collectors started together, one
1969/// sweeps and the other finds the stamp fresh and exits.
1970pub fn sweep_is_due(store: &Path, interval: Duration) -> bool {
1971    !sweep_stamp_is_fresh(&store.join(SWEEP_STAMP), interval)
1972}
1973
1974fn sweep_stamp_is_fresh(stamp: &Path, interval: Duration) -> bool {
1975    std::fs::metadata(stamp)
1976        .and_then(|metadata| metadata.modified())
1977        .ok()
1978        .and_then(|modified| modified.elapsed().ok())
1979        .is_some_and(|since| since < interval)
1980}
1981
1982/// What the checkout registry says about the identities in this store.
1983struct CheckoutScan {
1984    live_identities: BTreeSet<String>,
1985    live_records: u64,
1986    stale_records: Vec<PathBuf>,
1987}
1988
1989fn scan_checkouts(store: &Path) -> Result<CheckoutScan> {
1990    let root = store.join(CHECKOUTS_DIR);
1991    let mut scan = CheckoutScan {
1992        live_identities: BTreeSet::new(),
1993        live_records: 0,
1994        stale_records: Vec::new(),
1995    };
1996    let listing = match std::fs::read_dir(&root) {
1997        Ok(listing) => listing,
1998        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(scan),
1999        Err(error) => {
2000            return Err(error).wrap_err_with(|| format!("failed to read {}", root.display()));
2001        }
2002    };
2003    for entry in listing {
2004        let entry = entry?;
2005        let name = entry.file_name().to_string_lossy().into_owned();
2006        // Anything that is not a directory named for an identity was not written
2007        // by mbx. Both halves matter: `walk_files` tolerates only a missing
2008        // directory, so a plain file whose name happens to look like an identity
2009        // would fail the whole scan -- and that scan runs inside every sweep,
2010        // including the automatic one after a build.
2011        if !is_task_identity(&name) || !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
2012            continue;
2013        }
2014        for record in walk_files(&entry.path())? {
2015            match read_checkout_record(&record.path) {
2016                Some(checkout) if claim_is_live(store, &checkout) => {
2017                    scan.live_identities.insert(name.clone());
2018                    scan.live_records += 1;
2019                }
2020                Some(_) => scan.stale_records.push(record.path),
2021                // A record this build cannot read claims nothing, but it is not
2022                // evidence the checkout is gone either, so leave it alone.
2023                None => {}
2024            }
2025        }
2026    }
2027    Ok(scan)
2028}
2029
2030fn read_checkout_record(path: &Path) -> Option<CheckoutRecord> {
2031    let bytes = std::fs::read(path).ok()?;
2032    let record = serde_json::from_slice::<CheckoutRecord>(&bytes).ok()?;
2033    (record.version == CHECKOUT_RECORD_VERSION).then_some(record)
2034}
2035
2036/// Actions whose successful grouped export has not retired its receipts yet.
2037fn grouped_receipt_actions(store: &Path) -> Result<BTreeSet<CacheDigest>> {
2038    let root = store.join(BUILD_RECEIPTS_DIR).join("groups");
2039    Ok(walk_files(&root)?
2040        .into_iter()
2041        .filter_map(|entry| read_build_receipt(&entry.path))
2042        .flat_map(|receipt| receipt.predictions.into_iter())
2043        .map(|prediction| prediction.action)
2044        .collect())
2045}
2046
2047/// Whether a recorded claim still speaks for a checkout that is using this
2048/// store.
2049fn claim_is_live(store: &Path, record: &CheckoutRecord) -> bool {
2050    checkout_is_live_on(store, &record.workspace_root) && !claim_has_expired(record)
2051}
2052
2053fn claim_has_expired(record: &CheckoutRecord) -> bool {
2054    let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
2055        return false;
2056    };
2057    now.as_secs()
2058        .saturating_sub(record.updated_secs)
2059        .gt(&CHECKOUT_RETENTION.as_secs())
2060}
2061
2062/// Whether the checkout a record names is still on disk.
2063///
2064/// Absence is believed only when the checkout is definitely absent and its
2065/// nearest existing ancestor is on the same filesystem as the store. Walking
2066/// all the way to that ancestor matters for worktree managers and temporary
2067/// directories, which commonly remove a checkout together with one or more of
2068/// its otherwise-empty parents.
2069///
2070/// A different filesystem can be a mount whose contents are temporarily
2071/// unavailable, so uncertainty there remains live. Errors are treated the same
2072/// way: being wrong in that direction only delays collection; the other way
2073/// round throws away a warm cache someone is still using.
2074pub fn checkout_is_live_on(store: &Path, workspace_root: &Path) -> bool {
2075    if !matches!(workspace_root.try_exists(), Ok(false)) {
2076        return true;
2077    }
2078
2079    let Ok(store_metadata) = std::fs::metadata(store) else {
2080        return true;
2081    };
2082    for ancestor in workspace_root.ancestors().skip(1) {
2083        match std::fs::metadata(ancestor) {
2084            Ok(ancestor_metadata) => {
2085                return !same_filesystem(store, &store_metadata, ancestor, &ancestor_metadata);
2086            }
2087            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2088            Err(_) => return true,
2089        }
2090    }
2091    true
2092}
2093
2094/// Whether a checkout is still on disk, corroborating absence through its
2095/// immediate parent.
2096///
2097/// Prefer [`checkout_is_live_on`] when an existing store or managed-target
2098/// root is available to distinguish deletion from an unavailable filesystem.
2099pub fn checkout_is_live(workspace_root: &Path) -> bool {
2100    let Some(parent) = workspace_root.parent() else {
2101        return true;
2102    };
2103    checkout_is_live_on(parent, workspace_root)
2104}
2105
2106/// Whether two existing paths sit on the same filesystem, so a rename can
2107/// carry a directory from one to the other.
2108///
2109/// A path that cannot be inspected answers `false`: a move that cannot be
2110/// checked is not one to attempt.
2111pub fn same_filesystem_paths(a: &Path, b: &Path) -> bool {
2112    let (Ok(a_metadata), Ok(b_metadata)) = (std::fs::metadata(a), std::fs::metadata(b)) else {
2113        return false;
2114    };
2115    same_filesystem(a, &a_metadata, b, &b_metadata)
2116}
2117
2118#[cfg(unix)]
2119fn same_filesystem(
2120    _a_path: &Path,
2121    a: &std::fs::Metadata,
2122    _b_path: &Path,
2123    b: &std::fs::Metadata,
2124) -> bool {
2125    use std::os::unix::fs::MetadataExt as _;
2126    a.dev() == b.dev()
2127}
2128
2129#[cfg(windows)]
2130fn same_filesystem(
2131    a_path: &Path,
2132    _a: &std::fs::Metadata,
2133    b_path: &Path,
2134    _b: &std::fs::Metadata,
2135) -> bool {
2136    windows_volume_name(a_path).is_some_and(|volume| windows_volume_name(b_path) == Some(volume))
2137}
2138
2139#[cfg(windows)]
2140fn windows_volume_name(path: &Path) -> Option<Vec<u16>> {
2141    use std::os::windows::ffi::OsStrExt as _;
2142    use windows_sys::Win32::Storage::FileSystem::{
2143        GetVolumeNameForVolumeMountPointW, GetVolumePathNameW,
2144    };
2145
2146    let path = path
2147        .as_os_str()
2148        .encode_wide()
2149        .chain(Some(0))
2150        .collect::<Vec<_>>();
2151    // Both APIs document MAX_PATH-sized output buffers for volume paths and
2152    // names. Failure is uncertainty, which deliberately keeps the checkout
2153    // live.
2154    let mut mount = vec![0_u16; 261];
2155    // SAFETY: `path` is nul-terminated and `mount` is writable for the stated
2156    // number of UTF-16 code units.
2157    if unsafe { GetVolumePathNameW(path.as_ptr(), mount.as_mut_ptr(), mount.len() as u32) } == 0 {
2158        return None;
2159    }
2160    let mut volume = vec![0_u16; 261];
2161    // SAFETY: the successful call above left `mount` nul-terminated, and
2162    // `volume` is writable for the stated number of UTF-16 code units.
2163    if unsafe {
2164        GetVolumeNameForVolumeMountPointW(mount.as_ptr(), volume.as_mut_ptr(), volume.len() as u32)
2165    } == 0
2166    {
2167        return None;
2168    }
2169    volume.truncate(volume.iter().position(|unit| *unit == 0)?);
2170    Some(volume)
2171}
2172
2173#[cfg(not(any(unix, windows)))]
2174fn same_filesystem(
2175    _a_path: &Path,
2176    _a: &std::fs::Metadata,
2177    _b_path: &Path,
2178    _b: &std::fs::Metadata,
2179) -> bool {
2180    false
2181}
2182
2183/// CAS paths reachable from the builds that live checkouts still depend on.
2184///
2185/// Recursing into output trees is the point: the descriptor blobs are tiny and
2186/// the leaf artifacts are where the bytes are, so rooting that stopped at the
2187/// top level would protect nothing worth protecting. Every read here is
2188/// tolerant -- a blob that has already been evicted, or that no longer parses,
2189/// simply roots less, and rooting less can only mean collecting sooner.
2190fn rooted_objects(store: &Path, identities: &BTreeSet<String>) -> Result<HashSet<PathBuf>> {
2191    let mut actions = BTreeSet::new();
2192    for identity in identities {
2193        // One manifest this build cannot read is not worth abandoning a sweep
2194        // over. It roots nothing, which can only mean collecting sooner.
2195        let manifest_actions = match task_manifest_actions(store, identity) {
2196            Ok(actions) => actions,
2197            Err(error) => {
2198                log::debug!("could not read the manifest for {identity}: {error}");
2199                continue;
2200            }
2201        };
2202        actions.extend(manifest_actions);
2203    }
2204    Ok(rooted_action_objects(store, actions))
2205}
2206
2207/// Return every local CAS path needed to restore the supplied actions.
2208fn rooted_action_objects(
2209    store: &Path,
2210    actions: impl IntoIterator<Item = CacheDigest>,
2211) -> HashSet<PathBuf> {
2212    let cas = LocalCas::new(store);
2213    let mut rooted = HashSet::new();
2214    let mut visited = BTreeSet::new();
2215    for action in actions {
2216        let Some(result) = read_action_result(store, &action) else {
2217            continue;
2218        };
2219        root_digest(&cas, &mut rooted, &result.action);
2220        if let Some(metadata) = &result.metadata {
2221            root_digest(&cas, &mut rooted, metadata);
2222            // The metadata blob is a descriptor too: it names the captured
2223            // stdout and stderr, and a restore reads both. Stopping at the
2224            // descriptor would leave the diagnostics of every action
2225            // unrooted -- including the one empty blob that every silent
2226            // compilation shares, which is enough on its own to turn a
2227            // rooted hit back into a miss.
2228            if let Some(rustc) = read_rustc_metadata(&cas, metadata) {
2229                root_digest(&cas, &mut rooted, &rustc.stdout);
2230                root_digest(&cas, &mut rooted, &rustc.stderr);
2231            }
2232        }
2233        if let Some(output_root) = &result.output_root {
2234            root_digest(&cas, &mut rooted, output_root);
2235            root_tree(&cas, output_root, &mut rooted, &mut visited);
2236        }
2237    }
2238    rooted
2239}
2240
2241fn read_action_result(store: &Path, action: &CacheDigest) -> Option<RemoteActionResult> {
2242    let path = mbx_cache_core::LocalActionCache::new(store)
2243        .path_for(action)
2244        .ok()?;
2245    let bytes = std::fs::read(path).ok()?;
2246    serde_json::from_slice(&bytes).ok()
2247}
2248
2249fn root_tree(
2250    cas: &LocalCas,
2251    output_root: &CacheDigest,
2252    rooted: &mut HashSet<PathBuf>,
2253    visited: &mut BTreeSet<CacheDigest>,
2254) {
2255    let mut pending = vec![output_root.clone()];
2256    while let Some(digest) = pending.pop() {
2257        if !visited.insert(digest.clone()) {
2258            continue;
2259        }
2260        let Some(directory) = read_directory(cas, &digest) else {
2261            continue;
2262        };
2263        for file in &directory.files {
2264            root_digest(cas, rooted, &file.digest);
2265        }
2266        for child in &directory.directories {
2267            root_digest(cas, rooted, &child.digest);
2268            pending.push(child.digest.clone());
2269        }
2270    }
2271}
2272
2273fn read_rustc_metadata(cas: &LocalCas, digest: &CacheDigest) -> Option<RustcMetadata> {
2274    let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2275    serde_json::from_slice(&bytes).ok()
2276}
2277
2278fn read_directory(cas: &LocalCas, digest: &CacheDigest) -> Option<CacheDirectory> {
2279    let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2280    serde_json::from_slice(&bytes).ok()
2281}
2282
2283fn root_digest(cas: &LocalCas, rooted: &mut HashSet<PathBuf>, digest: &CacheDigest) {
2284    if let Ok(path) = cas.path_for(digest) {
2285        rooted.insert(path);
2286    }
2287}
2288
2289#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2290enum Removal {
2291    Removed,
2292    Missing,
2293    Blocked,
2294}
2295
2296fn remove(path: &Path) -> Result<Removal> {
2297    match std::fs::remove_file(path) {
2298        Ok(()) => Ok(Removal::Removed),
2299        // A concurrent build may have evicted or replaced it already.
2300        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Removal::Missing),
2301        // Windows refuses to unlink a file another process holds open, which is
2302        // what a concurrent build reading this blob looks like. Skipping it
2303        // leaves the store over budget until the next sweep; failing would
2304        // abandon the sweep and leave it over budget for longer.
2305        Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2306            log::debug!("could not evict {}: {error}", path.display());
2307            Ok(Removal::Blocked)
2308        }
2309        Err(error) => Err(error).wrap_err_with(|| format!("failed to evict {}", path.display())),
2310    }
2311}
2312
2313#[derive(Debug, Default, PartialEq, Eq)]
2314struct ObjectEvictions {
2315    removed_objects: u64,
2316    removed_bytes: u64,
2317    remaining_bytes: u64,
2318}
2319
2320/// Evict sorted objects without crossing the checkout-protection boundary
2321/// when an unrooted object cannot be removed.
2322fn evict_objects(
2323    objects: &[Entry],
2324    rooted: &HashSet<PathBuf>,
2325    mut live_bytes: u64,
2326    max_bytes: u64,
2327    mut remove_file: impl FnMut(&Path) -> Result<Removal>,
2328) -> Result<ObjectEvictions> {
2329    let mut outcome = ObjectEvictions::default();
2330    let mut blocked_unrooted = false;
2331    for entry in objects {
2332        if live_bytes <= max_bytes {
2333            break;
2334        }
2335        let is_rooted = rooted.contains(&entry.path);
2336        if is_rooted && blocked_unrooted {
2337            // A locked unrooted blob is temporarily part of the irreducible
2338            // store. Deleting a live checkout's artifacts in its place would
2339            // invert the protection ordering this collector promises.
2340            break;
2341        }
2342        match remove_file(&entry.path)? {
2343            Removal::Removed => {
2344                live_bytes = live_bytes.saturating_sub(entry.size);
2345                outcome.removed_objects += 1;
2346                outcome.removed_bytes += entry.size;
2347            }
2348            Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
2349            Removal::Blocked if !is_rooted => blocked_unrooted = true,
2350            Removal::Blocked => {}
2351        }
2352    }
2353    outcome.remaining_bytes = live_bytes;
2354    Ok(outcome)
2355}
2356
2357/// Whether an action result references an object the store no longer has.
2358///
2359/// The digests checked here are exactly the ones `LocalActionCache::store`
2360/// requires. If the sweep checked fewer, eviction could leave an entry that
2361/// cannot be republished -- `store` would reject the identical result for a
2362/// missing blob while the index still claimed to hold it.
2363///
2364/// Presence is all that is checked, not content. Verifying would re-hash a
2365/// large fraction of the store on every sweep, which a chore could afford and
2366/// an automatic sweep cannot; it would also refresh the access time of every
2367/// descriptor blob while leaving the artifacts alone, biasing the very
2368/// ordering this module depends on. A blob that is present but corrupt now
2369/// keeps its result and turns into a miss on restore, and both CAS write paths
2370/// republish over a corrupt blob rather than trusting it.
2371///
2372/// Only the top-level objects are checked; a result whose output tree lost a
2373/// nested object still restores as a miss, which is safe.
2374fn action_result_is_dangling(
2375    cas: &LocalCas,
2376    path: &Path,
2377    virtually_removed: &HashSet<PathBuf>,
2378) -> Result<bool> {
2379    let bytes = match std::fs::read(path) {
2380        Ok(bytes) => bytes,
2381        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
2382        Err(error) => {
2383            return Err(error).wrap_err_with(|| format!("failed to read {}", path.display()));
2384        }
2385    };
2386    // An unparseable result is already useless; the cache rejects it on read.
2387    let Ok(result) = serde_json::from_slice::<RemoteActionResult>(&bytes) else {
2388        return Ok(false);
2389    };
2390    for digest in [
2391        Some(&result.action),
2392        result.metadata.as_ref(),
2393        result.output_root.as_ref(),
2394    ]
2395    .into_iter()
2396    .flatten()
2397    {
2398        match cas.path_for(digest) {
2399            Ok(path) => {
2400                if virtually_removed.contains(&path) || !path.exists() {
2401                    return Ok(true);
2402                }
2403            }
2404            // A digest the CAS cannot even address is not one it can hold.
2405            Err(_) => return Ok(true),
2406        }
2407    }
2408    Ok(false)
2409}
2410
2411struct Entry {
2412    path: PathBuf,
2413    size: u64,
2414    used: SystemTime,
2415}
2416
2417fn walk_files(root: &Path) -> Result<Vec<Entry>> {
2418    let mut entries = Vec::new();
2419    let mut pending = vec![root.to_path_buf()];
2420    while let Some(directory) = pending.pop() {
2421        let listing = match std::fs::read_dir(&directory) {
2422            Ok(listing) => listing,
2423            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2424            Err(error) => {
2425                return Err(error)
2426                    .wrap_err_with(|| format!("failed to read {}", directory.display()));
2427            }
2428        };
2429        for entry in listing {
2430            let entry = entry?;
2431            let metadata = match entry.metadata() {
2432                Ok(metadata) => metadata,
2433                // A concurrent build may be publishing into the store.
2434                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2435                Err(error) => return Err(error.into()),
2436            };
2437            if metadata.is_dir() {
2438                pending.push(entry.path());
2439            } else if metadata.is_file() {
2440                let used = metadata
2441                    .accessed()
2442                    .or_else(|_| metadata.modified())
2443                    .unwrap_or(SystemTime::UNIX_EPOCH);
2444                entries.push(Entry {
2445                    path: entry.path(),
2446                    size: metadata.len(),
2447                    used,
2448                });
2449            }
2450        }
2451    }
2452    Ok(entries)
2453}
2454
2455fn read_dir_or_empty(root: &Path) -> Result<Vec<std::fs::DirEntry>> {
2456    match std::fs::read_dir(root) {
2457        Ok(entries) => entries
2458            .collect::<std::io::Result<Vec<_>>>()
2459            .map_err(Into::into),
2460        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
2461        Err(error) => Err(error).wrap_err_with(|| format!("failed to read {}", root.display())),
2462    }
2463}
2464
2465fn digest_from_path(path: &Path, action_result: bool) -> Option<CacheDigest> {
2466    let name = path.file_name()?.to_str()?;
2467    let name = if action_result {
2468        name.strip_suffix(".json")?
2469    } else {
2470        name
2471    };
2472    let (hash, size) = name.rsplit_once('-')?;
2473    let algorithm = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
2474    let digest = CacheDigest {
2475        algorithm,
2476        hash: hash.to_string(),
2477        size: size.parse().ok()?,
2478    };
2479    digest.validate().ok()?;
2480    Some(digest)
2481}
2482
2483fn addressed_digest(store: &Path, path: &Path, action_result: bool) -> Option<CacheDigest> {
2484    let digest = digest_from_path(path, action_result)?;
2485    let canonical = if action_result {
2486        mbx_cache_core::LocalActionCache::new(store)
2487            .path_for(&digest)
2488            .ok()?
2489    } else {
2490        LocalCas::new(store).path_for(&digest).ok()?
2491    };
2492    (canonical == path).then_some(digest)
2493}
2494
2495fn cached_tree_bytes(cache: &mut BTreeMap<PathBuf, u64>, root: &Path) -> u64 {
2496    *cache
2497        .entry(root.to_path_buf())
2498        .or_insert_with(|| tree_bytes(root))
2499}
2500
2501fn tree_bytes(root: &Path) -> u64 {
2502    let Ok(root_metadata) = std::fs::metadata(root) else {
2503        return 0;
2504    };
2505    if root_metadata.is_file() {
2506        return root_metadata.len();
2507    }
2508    let Ok(entries) = std::fs::read_dir(root) else {
2509        return 0;
2510    };
2511    let mut bytes = 0_u64;
2512    let mut pending = entries
2513        .filter_map(std::result::Result::ok)
2514        .map(|entry| entry.path())
2515        .collect::<Vec<_>>();
2516    while let Some(path) = pending.pop() {
2517        let Ok(metadata) = std::fs::symlink_metadata(&path) else {
2518            continue;
2519        };
2520        if metadata.is_file() {
2521            bytes = bytes.saturating_add(metadata.len());
2522        } else if metadata.is_dir()
2523            && let Ok(entries) = std::fs::read_dir(path)
2524        {
2525            pending.extend(
2526                entries
2527                    .filter_map(std::result::Result::ok)
2528                    .map(|entry| entry.path()),
2529            );
2530        }
2531    }
2532    bytes
2533}
2534
2535#[cfg(test)]
2536#[path = "store_tests.rs"]
2537mod tests;
2538
2539/// [`write_atomic`] without the sync: for records every build rewrites and
2540/// that a torn or missing file only makes one build older.
2541fn write_advisory(path: &Path, contents: &[u8]) -> Result<()> {
2542    let parent = path
2543        .parent()
2544        .filter(|parent| !parent.as_os_str().is_empty())
2545        .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
2546    std::fs::create_dir_all(parent)
2547        .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
2548    let mut temporary = tempfile::Builder::new()
2549        .prefix(".mbx-")
2550        .tempfile_in(parent)?;
2551    use std::io::Write as _;
2552    temporary.write_all(contents)?;
2553    temporary
2554        .persist(path)
2555        .map_err(|error| error.error)
2556        .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
2557    Ok(())
2558}
2559
2560fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
2561    let parent = path
2562        .parent()
2563        .filter(|parent| !parent.as_os_str().is_empty())
2564        .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
2565    std::fs::create_dir_all(parent)
2566        .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
2567    let mut temporary = tempfile::Builder::new()
2568        .prefix(".mbx-")
2569        .tempfile_in(parent)?;
2570    use std::io::Write as _;
2571    temporary.write_all(contents)?;
2572    temporary.as_file_mut().sync_all()?;
2573    temporary
2574        .persist(path)
2575        .map_err(|error| error.error)
2576        .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
2577    Ok(())
2578}