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. Publication can still fail (for example on store I/O or
757    // a concurrent invalid action write); it does not roll back moved objects.
758    // Intact conflicting results follow the first-writer rule and are kept.
759    let results = closure
760        .results
761        .iter()
762        .map(|path| {
763            let result: RemoteActionResult = serde_json::from_slice(&std::fs::read(path)?)
764                .wrap_err_with(|| format!("action result is invalid: {}", path.display()))?;
765            Ok(result)
766        })
767        .collect::<Result<Vec<_>>>()
768        .wrap_err("cache export is incomplete or corrupt")?;
769
770    let cas = LocalCas::new(store);
771    for path in &closure.objects {
772        let relative = path.strip_prefix(root)?;
773        let source = root.join(relative);
774        let digest = addressed_digest(root, &source, false)
775            .ok_or_else(|| eyre::eyre!("invalid cache object path {}", relative.display()))?;
776        // `strict_closure` verified every path in `objects` against its
777        // content-addressed name. Preserve that proof and move the owned
778        // staging file instead of hashing or copying the bytes again.
779        cas.adopt_verified_file(&digest, &source)?;
780    }
781    let action_cache = mbx_cache_core::LocalActionCache::new(store);
782    let mut kept_existing = 0;
783    for result in &results {
784        if action_cache.store_unless_restorable(result, |existing| {
785            extend_result_closure(
786                &cas,
787                &mut Closure::default(),
788                &mut BTreeSet::new(),
789                existing,
790                false,
791            )
792            .is_ok()
793        })? == mbx_cache_core::ActionStoreOutcome::KeptExisting
794        {
795            kept_existing += 1;
796        }
797    }
798    if kept_existing > 0 {
799        log::debug!(
800            "cache import kept {kept_existing} intact existing results that differed from the bundle"
801        );
802    }
803    for task in manifest.tasks {
804        merge_imported_manifest(store, task)?;
805    }
806    let bytes = match bundle_bytes {
807        Some(bytes) => {
808            // Publication moved the objects out of the bundle, so what is left
809            // is a shell of empty directories. Removing it keeps a restored
810            // bundle out of the job's disk budget for the rest of the run.
811            std::fs::remove_dir_all(archive)
812                .wrap_err_with(|| format!("failed to remove {}", archive.display()))?;
813            bytes
814        }
815        None => std::fs::metadata(archive)?.len(),
816    };
817    Ok(ImportOutcome {
818        transfer: TransferOutcome {
819            actions: actions.len() as u64,
820            objects: closure.objects.len() as u64,
821            bytes,
822        },
823        attachments: manifest.attachments,
824    })
825}
826
827fn validate_export_additions(additions: &ExportAdditions) -> Result<()> {
828    if additions
829        .attachments
830        .keys()
831        .any(|name| !valid_attachment_name(name))
832        || additions
833            .attachments
834            .values()
835            .any(|digest| !additions.objects.contains(digest))
836        || additions
837            .objects
838            .iter()
839            .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
840    {
841        eyre::bail!("invalid cache export attachment");
842    }
843    Ok(())
844}
845
846fn valid_attachment_name(name: &str) -> bool {
847    !name.is_empty()
848        && name.len() <= 128
849        && name
850            .bytes()
851            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
852}
853
854/// Everything one closure walk reached, and the leaves still to be hashed.
855#[derive(Default)]
856struct Closure {
857    objects: BTreeSet<PathBuf>,
858    results: BTreeSet<PathBuf>,
859    pending: Vec<PendingObject>,
860}
861
862/// A leaf object recorded by the walk and awaiting content verification.
863struct PendingObject {
864    digest: CacheDigest,
865    path: PathBuf,
866}
867
868fn strict_closure(store: &Path, actions: &BTreeSet<CacheDigest>) -> Result<Closure> {
869    let cas = LocalCas::new(store);
870    let action_cache = mbx_cache_core::LocalActionCache::new(store);
871    let mut closure = Closure::default();
872    let mut directories = BTreeSet::new();
873    for action in actions {
874        let result = action_cache
875            .find(action)?
876            .ok_or_else(|| eyre::eyre!("action result is missing for {}", action.hash))?;
877        closure.results.insert(action_cache.path_for(action)?);
878        extend_result_closure(&cas, &mut closure, &mut directories, &result, true)?;
879    }
880    Ok(closure)
881}
882
883/// Walk one result's dependencies. Export/import validation hashes descriptors;
884/// checking an existing result for repair only needs presence and length checks.
885fn extend_result_closure(
886    cas: &LocalCas,
887    closure: &mut Closure,
888    directories: &mut BTreeSet<CacheDigest>,
889    result: &RemoteActionResult,
890    verify_descriptors: bool,
891) -> Result<()> {
892    let parsed_object = |closure: &mut Closure, digest: &CacheDigest| {
893        if verify_descriptors {
894            require_parsed_object(cas, closure, digest)
895        } else {
896            require_object(cas, closure, digest)?;
897            cas.path_for(digest)
898        }
899    };
900    require_object(cas, closure, &result.action)?;
901    if let Some(metadata) = &result.metadata {
902        let path = parsed_object(closure, metadata)?;
903        let captured: CapturedOutput =
904            serde_json::from_slice(&std::fs::read(path)?).wrap_err("action metadata is invalid")?;
905        require_object(cas, closure, &captured.stdout)?;
906        require_object(cas, closure, &captured.stderr)?;
907    }
908    if let Some(root) = &result.output_root {
909        let mut nodes = vec![root.clone()];
910        while let Some(digest) = nodes.pop() {
911            if !directories.insert(digest.clone()) {
912                continue;
913            }
914            let path = parsed_object(closure, &digest)?;
915            let directory: CacheDirectory = serde_json::from_slice(&std::fs::read(path)?)
916                .wrap_err("output directory is invalid")?;
917            for file in directory.files {
918                require_object(cas, closure, &file.digest)?;
919            }
920            for child in directory.directories {
921                nodes.push(child.digest);
922            }
923        }
924    }
925    Ok(())
926}
927
928/// Verify every recorded leaf's contents, spread across the machine.
929///
930/// Callers must run this before publishing anything the walk reached: it is
931/// the step that proves the bytes match their content-addressed names, and
932/// both the exporter and the importer depend on that proof. A closure of a
933/// few thousand compiler outputs is several gigabytes read once, and the
934/// hash, not the read, is what saturates a core.
935fn verify_pending(pending: &mut [PendingObject]) -> Result<()> {
936    if pending.is_empty() {
937        return Ok(());
938    }
939    // Longest first. Handing out a half-gigabyte rlib last would leave one
940    // worker hashing it alone after the others have finished, and the lengths
941    // cost nothing: the walk already checked each one against its digest.
942    pending.sort_by(|left, right| {
943        right
944            .digest
945            .size
946            .cmp(&left.digest.size)
947            .then_with(|| left.path.cmp(&right.path))
948    });
949    let workers = std::thread::available_parallelism()
950        .map(usize::from)
951        .unwrap_or(1)
952        .min(pending.len());
953    let next = AtomicUsize::new(0);
954    // Report the lowest failing path rather than whichever worker happened to
955    // lose the race, so a corrupt closure names the same object every run.
956    let failure: Mutex<Option<(PathBuf, eyre::Report)>> = Mutex::new(None);
957    let pending: &[PendingObject] = pending;
958    std::thread::scope(|scope| {
959        for _ in 0..workers {
960            scope.spawn(|| {
961                loop {
962                    let index = next.fetch_add(1, Ordering::Relaxed);
963                    let Some(entry) = pending.get(index) else {
964                        break;
965                    };
966                    let error = match entry.digest.matches_file(&entry.path) {
967                        Ok(true) => continue,
968                        Ok(false) => eyre::eyre!(
969                            "local CAS blob failed digest verification: {}",
970                            entry.path.display()
971                        ),
972                        Err(error) => {
973                            error.wrap_err(format!("failed to verify {}", entry.path.display()))
974                        }
975                    };
976                    let mut failure = failure.lock().unwrap_or_else(|error| error.into_inner());
977                    if failure.as_ref().is_none_or(|(path, _)| entry.path < *path) {
978                        *failure = Some((entry.path.clone(), error));
979                    }
980                }
981            });
982        }
983    });
984    match failure
985        .into_inner()
986        .unwrap_or_else(|error| error.into_inner())
987    {
988        Some((_, error)) => Err(error),
989        None => Ok(()),
990    }
991}
992
993#[derive(Deserialize)]
994struct CapturedOutput {
995    stdout: CacheDigest,
996    stderr: CacheDigest,
997}
998
999fn merge_imported_manifest(destination: &Path, mut imported: TaskActionManifest) -> Result<()> {
1000    let identity = imported.task.clone();
1001    let destination_path = task_manifest_path(destination, &identity);
1002    let lock_path = task_manifest_lock_path(destination, &identity);
1003    std::fs::create_dir_all(lock_path.parent().expect("task manifest lock has a parent"))?;
1004    let mut lock = fslock::LockFile::open(&lock_path)?;
1005    lock.lock()?;
1006    if let Ok(bytes) = std::fs::read(&destination_path)
1007        && let Ok(existing) = serde_json::from_slice::<TaskActionManifest>(&bytes)
1008        && existing.task == identity
1009        && existing.validate()
1010    {
1011        let existing_predictions: BTreeMap<_, _> = existing
1012            .predictions
1013            .iter()
1014            .cloned()
1015            .map(|prediction| (prediction.invocation.clone(), prediction))
1016            .collect();
1017        // The bundle determines recency, but a checkout's current value wins
1018        // when both manifests predict the same invocation.
1019        let updates = imported
1020            .predictions
1021            .into_iter()
1022            .map(|prediction| {
1023                existing_predictions
1024                    .get(&prediction.invocation)
1025                    .cloned()
1026                    .unwrap_or(prediction)
1027            })
1028            .collect();
1029        imported.predictions =
1030            merge_task_action_predictions(existing.predictions, updates, &BTreeSet::new())?;
1031    }
1032    write_atomic(&destination_path, &serde_json::to_vec(&imported)?)
1033}
1034
1035/// Record a leaf object, deferring its content hash to `verify_pending`.
1036///
1037/// Presence and declared length are settled here, while the walk can still say
1038/// cheaply which object is wrong. The contents are left for the parallel pass
1039/// because leaves are where the bytes are: compiler outputs, captured streams,
1040/// and attachments, none of which steer the walk.
1041fn require_object(cas: &LocalCas, closure: &mut Closure, digest: &CacheDigest) -> Result<()> {
1042    let expected = cas.path_for(digest)?;
1043    if !closure.objects.insert(expected.clone()) {
1044        return Ok(());
1045    }
1046    let metadata = match std::fs::metadata(&expected) {
1047        Ok(metadata) => metadata,
1048        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1049            eyre::bail!("cache object is missing for {}", digest.hash)
1050        }
1051        Err(error) => {
1052            return Err(error).wrap_err_with(|| format!("failed to read {}", expected.display()));
1053        }
1054    };
1055    if metadata.len() != digest.size {
1056        eyre::bail!(
1057            "local CAS blob failed digest verification: {}",
1058            expected.display()
1059        );
1060    }
1061    closure.pending.push(PendingObject {
1062        digest: digest.clone(),
1063        path: expected,
1064    });
1065    Ok(())
1066}
1067
1068/// Verify an object the walk is about to deserialize, and return its path.
1069///
1070/// Action metadata and directory nodes decide what the walk visits next, so a
1071/// forged one steers the walk itself and has to be trusted before `serde_json`
1072/// sees it. They are canonical JSON and small, so hashing them in line costs
1073/// little next to the leaves they point at.
1074fn require_parsed_object(
1075    cas: &LocalCas,
1076    closure: &mut Closure,
1077    digest: &CacheDigest,
1078) -> Result<PathBuf> {
1079    let path = cas
1080        .find(digest)?
1081        .ok_or_else(|| eyre::eyre!("cache object is missing for {}", digest.hash))?;
1082    closure.objects.insert(path.clone());
1083    Ok(path)
1084}
1085
1086fn task_manifest_path(store: &Path, identity: &str) -> PathBuf {
1087    store
1088        .join("task-manifests/v1")
1089        .join(format!("{identity}.json"))
1090}
1091
1092fn task_manifest_lock_path(store: &Path, identity: &str) -> PathBuf {
1093    store
1094        .join("task-manifests/v1/locks")
1095        .join(format!("{identity}.lock"))
1096}
1097
1098fn append_file(builder: &mut tar::Builder<std::fs::File>, root: &Path, path: &Path) -> Result<()> {
1099    let name = path.strip_prefix(root)?;
1100    builder.append_path_with_name(path, name)?;
1101    Ok(())
1102}
1103
1104fn append_bytes(
1105    builder: &mut tar::Builder<std::fs::File>,
1106    name: &Path,
1107    bytes: &[u8],
1108) -> Result<()> {
1109    let mut header = tar::Header::new_gnu();
1110    header.set_size(bytes.len() as u64);
1111    header.set_mode(0o644);
1112    header.set_cksum();
1113    builder.append_data(&mut header, name, bytes)?;
1114    Ok(())
1115}
1116
1117/// Unpack a tar export into an empty staging directory.
1118fn unpack_archive(archive: &Path, staging: &Path) -> Result<()> {
1119    let file = std::fs::File::open(archive)
1120        .wrap_err_with(|| format!("failed to open {}", archive.display()))?;
1121    let mut bundle = tar::Archive::new(file);
1122    let mut seen = BTreeSet::new();
1123    for entry in bundle.entries()? {
1124        let mut entry = entry?;
1125        let entry_type = entry.header().entry_type();
1126        if !entry_type.is_file() && !entry_type.is_gnu_sparse() {
1127            eyre::bail!("cache export contains a non-file entry");
1128        }
1129        let path = entry.path()?.into_owned();
1130        validate_archive_path(&path)?;
1131        if !seen.insert(path.clone()) {
1132            eyre::bail!("cache export contains duplicate entry {}", path.display());
1133        }
1134        let destination = staging.join(&path);
1135        std::fs::create_dir_all(destination.parent().expect("entry has a parent"))?;
1136        // `unpack` understands GNU sparse maps. A plain stream copy expands
1137        // holes into physical zeroes, which is both slower and much larger for
1138        // Rust artifacts containing sparse sections.
1139        entry.unpack(&destination)?;
1140    }
1141    Ok(())
1142}
1143
1144/// Check a directory bundle against the policy a tar bundle is held to.
1145///
1146/// A tar carries its own entry types, so the importer can refuse a symlink or
1147/// a device node by reading the header. A directory has to be walked for the
1148/// same answer, and `DirEntry::metadata` does not follow symlinks, so anything
1149/// that is not a plain file or a directory is rejected here rather than
1150/// followed out of the bundle.
1151fn validate_directory_bundle(root: &Path) -> Result<()> {
1152    let mut pending = vec![root.to_path_buf()];
1153    while let Some(directory) = pending.pop() {
1154        for entry in std::fs::read_dir(&directory)
1155            .wrap_err_with(|| format!("failed to read {}", directory.display()))?
1156        {
1157            let entry = entry?;
1158            let metadata = entry.metadata()?;
1159            let path = entry.path();
1160            if metadata.is_dir() {
1161                pending.push(path);
1162                continue;
1163            }
1164            if !metadata.is_file() {
1165                eyre::bail!("cache export contains a non-file entry");
1166            }
1167            validate_archive_path(path.strip_prefix(root)?)?;
1168            reject_linked_file(&metadata, &path)?;
1169        }
1170    }
1171    Ok(())
1172}
1173
1174/// Refuse a bundle file that shares its inode with a name outside the bundle.
1175///
1176/// Import adopts objects by moving them into the CAS. A second hard link would
1177/// survive that move and keep write access to a blob the store then treats as
1178/// verified and immutable, which is a way to change a verified object after it
1179/// has been checked.
1180#[cfg(unix)]
1181fn reject_linked_file(metadata: &std::fs::Metadata, path: &Path) -> Result<()> {
1182    use std::os::unix::fs::MetadataExt as _;
1183
1184    if metadata.nlink() > 1 {
1185        eyre::bail!("cache export contains a hard link at {}", path.display());
1186    }
1187    Ok(())
1188}
1189
1190/// Windows reports a link count only through `MetadataExt::number_of_links`,
1191/// which is unstable, so this check cannot be made there without reaching for
1192/// the platform API directly. The gap is narrow -- it needs an attacker who
1193/// can already write into the job's filesystem before the import runs -- but
1194/// it is a gap, and a Windows directory bundle does not get this protection.
1195#[cfg(not(unix))]
1196fn reject_linked_file(_metadata: &std::fs::Metadata, _path: &Path) -> Result<()> {
1197    Ok(())
1198}
1199
1200fn validate_archive_path(path: &Path) -> Result<()> {
1201    if path.as_os_str().is_empty()
1202        || path.is_absolute()
1203        || path
1204            .components()
1205            .any(|component| !matches!(component, Component::Normal(_)))
1206    {
1207        eyre::bail!("cache export contains unsafe path {}", path.display());
1208    }
1209    // Compare components rather than a spelling. A tar entry always writes its
1210    // path with forward slashes, but a directory bundle is walked with the
1211    // platform's separator, so on Windows a prefix match against "cas/v1/"
1212    // would reject every file in the bundle.
1213    let parts = path
1214        .components()
1215        .map(|component| component.as_os_str().to_string_lossy().into_owned())
1216        .collect::<Vec<_>>();
1217    let expected = match parts.as_slice() {
1218        [name] => name == EXPORT_MANIFEST,
1219        [root, version, _rest @ ..] => {
1220            parts.len() > 2 && (root == "cas" || root == "action-results") && version == "v1"
1221        }
1222        [] => false,
1223    };
1224    if !expected {
1225        eyre::bail!("cache export contains unexpected path {}", path.display());
1226    }
1227    Ok(())
1228}
1229
1230/// One checkout's claim on the actions a build identity recorded.
1231///
1232/// The target directory is not read back by anything yet; it is recorded
1233/// because it is the other half of what the shim mapped, and a record that
1234/// names only half of it would have to be rewritten to answer where the
1235/// artifacts went.
1236#[derive(Debug, Clone, Serialize, Deserialize)]
1237#[serde(deny_unknown_fields)]
1238struct CheckoutRecord {
1239    version: u8,
1240    workspace_root: PathBuf,
1241    target_dir: PathBuf,
1242    updated_secs: u64,
1243}
1244
1245/// Summarize what the store currently holds.
1246pub fn stats(store: &Path) -> Result<StoreStats> {
1247    let objects = walk_files(&store.join(CAS_DIR))?;
1248    let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
1249    let checkouts = scan_checkouts(store)?;
1250    Ok(StoreStats {
1251        objects: objects.len() as u64,
1252        object_bytes: objects.iter().map(|entry| entry.size).sum(),
1253        action_results: results.len() as u64,
1254        action_result_bytes: results.iter().map(|entry| entry.size).sum(),
1255        live_checkouts: checkouts.live_records,
1256        stale_checkouts: checkouts.stale_records.len() as u64,
1257    })
1258}
1259
1260/// Size the target tree a checkout record names, if it names one.
1261///
1262/// A build with no target directory of its own -- one driven by make or CMake,
1263/// which writes wherever it was told -- records the checkout in that field
1264/// rather than inventing a directory. Walking it would report the whole source
1265/// tree, `.git` included, as though it were build output.
1266fn checkout_target_bytes(sizes: &mut BTreeMap<PathBuf, u64>, record: &CheckoutRecord) -> u64 {
1267    if record.target_dir == record.workspace_root {
1268        return 0;
1269    }
1270    cached_tree_bytes(sizes, &record.target_dir)
1271}
1272
1273/// Attribute cache and target bytes to each recorded workspace.
1274///
1275/// Shared objects are counted for every workspace that can reach them. That
1276/// makes each row answer "how much keeps this workspace warm" without
1277/// pretending shared storage can be divided exactly between projects.
1278pub fn projects(store: &Path) -> Result<Vec<ProjectUsage>> {
1279    project_usage(store, true)
1280}
1281
1282/// The reusable cache bytes of each live workspace, as [`projects`] counts
1283/// them.
1284///
1285/// Sizing target directories is most of what [`projects`] costs on a machine
1286/// with many checkouts, and an estimate of cache sharing never looks at them.
1287pub fn live_project_cache_bytes(store: &Path) -> Result<Vec<u64>> {
1288    Ok(project_usage(store, false)?
1289        .into_iter()
1290        .filter(|project| project.live)
1291        .map(|project| project.action_bytes)
1292        .collect())
1293}
1294
1295fn project_usage(store: &Path, size_targets: bool) -> Result<Vec<ProjectUsage>> {
1296    let mut projects: BTreeMap<PathBuf, (BTreeSet<String>, bool, u64)> = BTreeMap::new();
1297    let mut target_sizes = BTreeMap::new();
1298    let root = store.join(CHECKOUTS_DIR);
1299    for identity_entry in read_dir_or_empty(&root)? {
1300        let identity = identity_entry.file_name().to_string_lossy().into_owned();
1301        if !is_task_identity(&identity) || !identity_entry.file_type()?.is_dir() {
1302            continue;
1303        }
1304        for entry in walk_files(&identity_entry.path())? {
1305            let Some(record) = read_checkout_record(&entry.path) else {
1306                continue;
1307            };
1308            let project = projects.entry(record.workspace_root.clone()).or_default();
1309            if claim_is_live(store, &record) {
1310                project.0.insert(identity.clone());
1311                project.1 = true;
1312            }
1313            if size_targets {
1314                project.2 = project
1315                    .2
1316                    .max(checkout_target_bytes(&mut target_sizes, &record));
1317            }
1318        }
1319    }
1320    let live = projects
1321        .values()
1322        .flat_map(|(identities, _, _)| identities.iter().cloned())
1323        .collect::<BTreeSet<_>>();
1324    let mut reachability = Reachability::new(store);
1325    resolve_identities(&reachability, &live);
1326    let mut usages = Vec::new();
1327    for (workspace_root, (identities, live, target_bytes)) in projects {
1328        let action_bytes = reachability.bytes(&identities);
1329        usages.push(ProjectUsage {
1330            workspace_root,
1331            identities: identities.len() as u64,
1332            action_bytes,
1333            target_bytes,
1334            live,
1335        });
1336    }
1337    usages.sort_by(|left, right| {
1338        right
1339            .action_bytes
1340            .saturating_add(right.target_bytes)
1341            .cmp(&left.action_bytes.saturating_add(left.target_bytes))
1342            .then_with(|| left.workspace_root.cmp(&right.workspace_root))
1343    });
1344    Ok(usages)
1345}
1346
1347/// Resolve every identity's recorded actions into `reachability`.
1348///
1349/// Manifests are the one large input: a busy checkout's runs to tens of
1350/// megabytes of JSON, and parsing hundreds of them one after another is most
1351/// of what sizing every workspace costs. Resolving what they reach is mostly
1352/// cold file reads. Both are spread over a few threads, each taking whole
1353/// identities from a shared counter.
1354fn resolve_identities(reachability: &Reachability, identities: &BTreeSet<String>) {
1355    const MAX_WORKERS: usize = 8;
1356    let identities = identities.iter().collect::<Vec<_>>();
1357    let workers = std::thread::available_parallelism()
1358        .map(usize::from)
1359        .unwrap_or(1)
1360        .min(MAX_WORKERS)
1361        .min(identities.len());
1362    let next = AtomicUsize::new(0);
1363    std::thread::scope(|scope| {
1364        for _ in 0..workers {
1365            scope.spawn(|| {
1366                while let Some(identity) = identities.get(next.fetch_add(1, Ordering::Relaxed)) {
1367                    // One manifest this build cannot read is not worth
1368                    // abandoning the report over. It reaches nothing, which
1369                    // can only understate sharing.
1370                    let actions = task_manifest_actions(&reachability.store, identity)
1371                        .unwrap_or_else(|error| {
1372                            log::debug!("could not read the manifest for {identity}: {error}");
1373                            Vec::new()
1374                        });
1375                    reachability.record(identity, &actions);
1376                }
1377            });
1378        }
1379    });
1380}
1381
1382/// The store paths each identity can reach, resolved once for every workspace.
1383///
1384/// Workspaces share identities, identities share most of their actions, and
1385/// actions share output trees. Answering each workspace from scratch reread
1386/// the same manifests, results, and trees, and stat-ed the same objects, once
1387/// per workspace, which on a machine with a couple of hundred checkouts was
1388/// most of the half minute `mbx stats` took. Here every file is read and
1389/// stat-ed at most once, and a workspace's total is a union of indices already
1390/// in memory.
1391///
1392/// What is reachable matches [`rooted_objects`] plus the action-result records
1393/// themselves, and every read is as tolerant as it is there.
1394struct Reachability {
1395    store: PathBuf,
1396    cas: LocalCas,
1397    action_cache: mbx_cache_core::LocalActionCache,
1398    /// Every indexed path and its size, zero if it no longer exists.
1399    paths: Mutex<PathIndex>,
1400    identities: Mutex<HashMap<String, ActionPaths>>,
1401    actions: Mutex<HashMap<CacheDigest, Arc<[usize]>>>,
1402    /// Each tree blob's child directories and the paths it names directly.
1403    directories: Mutex<HashMap<CacheDigest, Option<DirectoryPaths>>>,
1404    /// The workspace number that last counted each path.
1405    counted: Vec<usize>,
1406    workspaces: usize,
1407}
1408
1409#[derive(Default)]
1410struct PathIndex {
1411    indices: HashMap<PathBuf, usize>,
1412    sizes: Vec<u64>,
1413}
1414
1415/// The paths each of an identity's actions reaches.
1416type ActionPaths = Arc<[Arc<[usize]>]>;
1417
1418type DirectoryPaths = (Arc<[CacheDigest]>, Arc<[usize]>);
1419
1420/// The caches are only guards around maps; a panicked holder leaves them
1421/// consistent, so poisoning is not worth failing a report over.
1422fn locked<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
1423    mutex.lock().unwrap_or_else(|error| error.into_inner())
1424}
1425
1426impl Reachability {
1427    fn new(store: &Path) -> Self {
1428        Self {
1429            store: store.to_path_buf(),
1430            cas: LocalCas::new(store),
1431            action_cache: mbx_cache_core::LocalActionCache::new(store),
1432            paths: Mutex::default(),
1433            identities: Mutex::default(),
1434            actions: Mutex::default(),
1435            directories: Mutex::default(),
1436            counted: Vec::new(),
1437            workspaces: 0,
1438        }
1439    }
1440
1441    /// Bytes reachable from any of `identities`, each path counted once.
1442    ///
1443    /// An identity that was never recorded reaches nothing.
1444    fn bytes(&mut self, identities: &BTreeSet<String>) -> u64 {
1445        let reachable = {
1446            let recorded = locked(&self.identities);
1447            identities
1448                .iter()
1449                .filter_map(|identity| recorded.get(identity).cloned())
1450                .collect::<Vec<_>>()
1451        };
1452        let sizes = std::mem::take(&mut locked(&self.paths).sizes);
1453        self.workspaces += 1;
1454        self.counted.resize(sizes.len(), 0);
1455        let mut bytes = 0_u64;
1456        for index in reachable
1457            .iter()
1458            .flat_map(|actions| actions.iter())
1459            .flat_map(|paths| paths.iter())
1460        {
1461            if self.counted[*index] != self.workspaces {
1462                self.counted[*index] = self.workspaces;
1463                bytes = bytes.saturating_add(sizes[*index]);
1464            }
1465        }
1466        locked(&self.paths).sizes = sizes;
1467        bytes
1468    }
1469
1470    /// Resolve what one identity's recorded actions reach.
1471    ///
1472    /// Safe to call from several threads. Two threads may resolve the same
1473    /// action at once; they read the same files and reach the same answer.
1474    fn record(&self, identity: &str, actions: &[CacheDigest]) {
1475        let actions = actions
1476            .iter()
1477            .map(|action| self.action(action))
1478            .collect::<Arc<[_]>>();
1479        locked(&self.identities).insert(identity.to_string(), actions);
1480    }
1481
1482    fn action(&self, action: &CacheDigest) -> Arc<[usize]> {
1483        if let Some(paths) = locked(&self.actions).get(action) {
1484            return paths.clone();
1485        }
1486        let mut paths = Vec::new();
1487        if let Ok(path) = self.action_cache.path_for(action) {
1488            paths.push(self.index(path));
1489        }
1490        if let Some(result) = read_action_result(&self.store, action) {
1491            self.push_digest(&mut paths, &result.action);
1492            if let Some(metadata) = &result.metadata {
1493                self.push_digest(&mut paths, metadata);
1494                if let Some(rustc) = read_rustc_metadata(&self.cas, metadata) {
1495                    self.push_digest(&mut paths, &rustc.stdout);
1496                    self.push_digest(&mut paths, &rustc.stderr);
1497                }
1498            }
1499            if let Some(output_root) = &result.output_root {
1500                self.push_digest(&mut paths, output_root);
1501                let mut pending = vec![output_root.clone()];
1502                let mut visited = HashSet::new();
1503                while let Some(digest) = pending.pop() {
1504                    if !visited.insert(digest.clone()) {
1505                        continue;
1506                    }
1507                    let Some((children, named)) = self.directory(&digest) else {
1508                        continue;
1509                    };
1510                    paths.extend(named.iter().copied());
1511                    pending.extend(children.iter().cloned());
1512                }
1513            }
1514        }
1515        paths.sort_unstable();
1516        paths.dedup();
1517        let paths = Arc::<[usize]>::from(paths);
1518        locked(&self.actions).insert(action.clone(), paths.clone());
1519        paths
1520    }
1521
1522    /// The child directories of a tree blob and the paths it names directly.
1523    fn directory(&self, digest: &CacheDigest) -> Option<DirectoryPaths> {
1524        if let Some(entry) = locked(&self.directories).get(digest) {
1525            return entry.clone();
1526        }
1527        let entry = read_directory(&self.cas, digest).map(|directory| {
1528            let mut paths = Vec::new();
1529            for file in &directory.files {
1530                self.push_digest(&mut paths, &file.digest);
1531            }
1532            for child in &directory.directories {
1533                self.push_digest(&mut paths, &child.digest);
1534            }
1535            let children = directory
1536                .directories
1537                .into_iter()
1538                .map(|child| child.digest)
1539                .collect::<Arc<[_]>>();
1540            (children, Arc::<[usize]>::from(paths))
1541        });
1542        locked(&self.directories).insert(digest.clone(), entry.clone());
1543        entry
1544    }
1545
1546    fn push_digest(&self, paths: &mut Vec<usize>, digest: &CacheDigest) {
1547        if let Ok(path) = self.cas.path_for(digest) {
1548            paths.push(self.index(path));
1549        }
1550    }
1551
1552    fn index(&self, path: PathBuf) -> usize {
1553        if let Some(index) = locked(&self.paths).indices.get(&path) {
1554            return *index;
1555        }
1556        // The stat runs outside the lock. A second thread may stat the same
1557        // path; whichever registers first wins and both get one index.
1558        let size = std::fs::metadata(&path).map_or(0, |metadata| metadata.len());
1559        let mut paths = locked(&self.paths);
1560        if let Some(index) = paths.indices.get(&path) {
1561            return *index;
1562        }
1563        let index = paths.sizes.len();
1564        paths.sizes.push(size);
1565        paths.indices.insert(path, index);
1566        index
1567    }
1568}
1569
1570/// Return the largest blobs and action-result records in descending order.
1571pub fn largest(store: &Path, limit: usize) -> Result<Vec<LargestEntry>> {
1572    let mut entries = Vec::new();
1573    for (kind, root, action_result) in [
1574        ("object", store.join(CAS_DIR), false),
1575        ("action result", store.join(ACTION_RESULTS_DIR), true),
1576    ] {
1577        entries.extend(
1578            walk_files(&root)?
1579                .into_iter()
1580                .filter(|entry| addressed_digest(store, &entry.path, action_result).is_some())
1581                .map(|entry| LargestEntry {
1582                    kind,
1583                    path: entry.path,
1584                    bytes: entry.size,
1585                }),
1586        );
1587    }
1588    entries.sort_by(|left, right| {
1589        right
1590            .bytes
1591            .cmp(&left.bytes)
1592            .then_with(|| left.path.cmp(&right.path))
1593    });
1594    entries.truncate(limit);
1595    Ok(entries)
1596}
1597
1598/// Verify every addressable CAS object and action-result record.
1599pub fn verify(store: &Path) -> Result<VerifyOutcome> {
1600    let cas = LocalCas::new(store);
1601    let action_cache = mbx_cache_core::LocalActionCache::new(store);
1602    // Verification reports the store as it is on disk and removes nothing, so
1603    // no object is pending removal the way one is mid-sweep.
1604    let virtually_removed = HashSet::new();
1605    let mut outcome = VerifyOutcome::default();
1606    for entry in walk_files(&store.join(CAS_DIR))? {
1607        let Some(digest) = addressed_digest(store, &entry.path, false) else {
1608            continue;
1609        };
1610        outcome.checked_objects += 1;
1611        if !matches!(cas.find(&digest), Ok(Some(_))) {
1612            outcome.problems.push(entry.path);
1613        }
1614    }
1615    for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
1616        let Some(digest) = addressed_digest(store, &entry.path, true) else {
1617            continue;
1618        };
1619        outcome.checked_action_results += 1;
1620        if !matches!(action_cache.find(&digest), Ok(Some(_)))
1621            || action_result_is_dangling(&cas, &entry.path, &virtually_removed)?
1622        {
1623            outcome.problems.push(entry.path);
1624        }
1625    }
1626    outcome.problems.sort();
1627    Ok(outcome)
1628}
1629
1630/// Remove checkout claims belonging to exactly one workspace.
1631pub fn remove_project(store: &Path, workspace_root: &Path) -> Result<RemoveProjectOutcome> {
1632    let mut outcome = RemoveProjectOutcome::default();
1633    for entry in walk_files(&store.join(CHECKOUTS_DIR))? {
1634        if read_checkout_record(&entry.path)
1635            .is_some_and(|record| record.workspace_root == workspace_root)
1636        {
1637            std::fs::remove_file(&entry.path)?;
1638            outcome.removed_checkout_records += 1;
1639            if let Some(parent) = entry.path.parent() {
1640                let _ = std::fs::remove_dir(parent);
1641            }
1642        }
1643    }
1644    match std::fs::remove_file(latest_receipt_path(store, workspace_root)) {
1645        Ok(()) => {}
1646        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1647        Err(error) => return Err(error.into()),
1648    }
1649    Ok(outcome)
1650}
1651
1652/// Record that `workspace_root` built `identity`.
1653///
1654/// Identities are shared by every checkout of one dependency graph, so this is
1655/// what later tells the collector whether anything still needs what a build
1656/// cached: one file per checkout under the identity it built. Writing a whole
1657/// file per checkout rather than merging a list into one keeps concurrent
1658/// builds out of each other's way -- there is nothing to merge, so there is no
1659/// lock and no lost update.
1660///
1661/// A build with no target directory of its own passes `workspace_root` for
1662/// `target_dir`, which reads as "none" rather than as a tree to measure.
1663pub fn record_checkout(
1664    store: &Path,
1665    identity: &str,
1666    workspace_root: &Path,
1667    target_dir: &Path,
1668) -> Result<()> {
1669    let record = CheckoutRecord {
1670        version: CHECKOUT_RECORD_VERSION,
1671        workspace_root: workspace_root.to_path_buf(),
1672        target_dir: target_dir.to_path_buf(),
1673        updated_secs: SystemTime::now()
1674            .duration_since(UNIX_EPOCH)
1675            .map(|since| since.as_secs())
1676            .unwrap_or_default(),
1677    };
1678    let mut contents = serde_json::to_vec(&record)?;
1679    contents.push(b'\n');
1680    // Refreshed by every build and read back only as a last-used stamp, so a
1681    // power cut costs one build's worth of recency, not a wait on the disk.
1682    write_advisory(
1683        &checkout_record_path(store, identity, workspace_root),
1684        &contents,
1685    )
1686}
1687
1688fn checkout_record_path(store: &Path, identity: &str, workspace_root: &Path) -> PathBuf {
1689    // The path is hashed rather than escaped: it is only ever compared against
1690    // the same hash of the same path, and every filesystem in play disagrees
1691    // about which characters a name may hold.
1692    let key = CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash;
1693    store
1694        .join(CHECKOUTS_DIR)
1695        .join(identity)
1696        .join(format!("{key}.json"))
1697}
1698
1699/// Evict objects until the store fits within `max_bytes`.
1700///
1701/// Objects no live checkout can reach are evicted first, so deleting a worktree
1702/// releases what only that worktree needed. Within each class eviction is
1703/// least-recently-used by access time where the filesystem records it, falling
1704/// back to modification time. A restore verifies the blob it serves, which
1705/// means it reads the whole file and the access time is real -- but only where
1706/// the mount records one, so the ordering is an approximation either way.
1707/// Evicting a live object costs a recompile, never correctness.
1708pub fn gc(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
1709    gc_with_mode(store, max_bytes, false)
1710}
1711
1712/// Describe the collection `gc` would perform without changing the store.
1713pub fn gc_dry_run(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
1714    gc_with_mode(store, max_bytes, true)
1715}
1716
1717fn gc_with_mode(store: &Path, max_bytes: u64, dry_run: bool) -> Result<GcOutcome> {
1718    let mut objects = walk_files(&store.join(CAS_DIR))?;
1719    let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
1720    let mut live_bytes = objects
1721        .iter()
1722        .chain(results.iter())
1723        .map(|entry| entry.size)
1724        .sum::<u64>();
1725
1726    let mut outcome = GcOutcome::default();
1727
1728    // Prune before deciding what is rooted, so a checkout deleted since the
1729    // last sweep stops protecting its artifacts during this one.
1730    let checkouts = scan_checkouts(store)?;
1731    for path in &checkouts.stale_records {
1732        if dry_run || matches!(remove(path)?, Removal::Removed) {
1733            outcome.removed_checkout_records += 1;
1734        }
1735        // The identity directory is left behind empty otherwise. It may hold
1736        // other checkouts, in which case this fails and that is the answer.
1737        if !dry_run && let Some(parent) = path.parent() {
1738            let _ = std::fs::remove_dir(parent);
1739        }
1740    }
1741
1742    let mut virtually_removed = HashSet::new();
1743    if live_bytes > max_bytes {
1744        // Reachability costs a read per action result and per output tree, so
1745        // it is computed only when something is actually about to be evicted.
1746        // Under budget a sweep stays a directory walk and nothing more.
1747        let mut rooted = rooted_objects(store, &checkouts.live_identities)?;
1748        // A grouped CI export deliberately retains the exact actions from
1749        // every command in the job. Later commands can replace the current
1750        // task-manifest prediction for the same invocation, but the earlier
1751        // receipt still has to remain exportable until the post-job step runs.
1752        // Receipt bookkeeping is not part of the cache budget, so consult it
1753        // only when a sweep is already necessary and root its object closure.
1754        rooted.extend(rooted_action_objects(
1755            store,
1756            grouped_receipt_actions(store)?,
1757        ));
1758        // `false` sorts first, so unrooted objects go before rooted ones and
1759        // each class goes oldest-first. A store with no records at all roots
1760        // nothing and this is exactly the LRU it was before.
1761        objects.sort_by_cached_key(|entry| (rooted.contains(&entry.path), entry.used));
1762        let evicted = evict_objects(&objects, &rooted, live_bytes, max_bytes, |path| {
1763            if dry_run {
1764                virtually_removed.insert(path.to_path_buf());
1765                Ok(Removal::Removed)
1766            } else {
1767                remove(path)
1768            }
1769        })?;
1770        live_bytes = evicted.remaining_bytes;
1771        outcome.removed_objects += evicted.removed_objects;
1772        outcome.removed_bytes += evicted.removed_bytes;
1773    }
1774
1775    // An action result whose objects are gone can only produce a miss, so drop
1776    // it rather than leave the index pointing at nothing. This runs whether or
1777    // not this call evicted anything, since another process may have, and a
1778    // store that is over budget on results alone still needs the sweep.
1779    let cas = LocalCas::new(store);
1780    for entry in &results {
1781        if action_result_is_dangling(&cas, &entry.path, &virtually_removed)? {
1782            match if dry_run {
1783                Removal::Removed
1784            } else {
1785                remove(&entry.path)?
1786            } {
1787                Removal::Removed => {
1788                    live_bytes = live_bytes.saturating_sub(entry.size);
1789                    outcome.removed_action_results += 1;
1790                    outcome.removed_bytes += entry.size;
1791                }
1792                Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
1793                Removal::Blocked => {}
1794            }
1795        }
1796    }
1797
1798    let sessions = prune_sessions(store, dry_run)?;
1799    outcome.removed_session_streams += sessions.removed_streams;
1800    outcome.removed_bytes += sessions.removed_bytes;
1801    prune_import_staging(store, dry_run);
1802
1803    outcome.remaining_bytes = live_bytes;
1804    Ok(outcome)
1805}
1806
1807/// Discard import staging trees that a killed process left behind.
1808///
1809/// A finished import removes its own tree, and a failed one unwinds through
1810/// `TempDir`, so only a kill leaks. What leaks is the whole expanded export,
1811/// and it sits outside `cas/v1` and `action-results/v1`, so the sweep's own
1812/// accounting cannot see it and nothing else would ever reclaim it.
1813///
1814/// Age is what makes this safe to do without a lock: a concurrent import
1815/// holds no claim this could consult, and one that has been extracting for a
1816/// day has worse problems than a stale directory. Failures are logged rather
1817/// than returned, because losing a staging tree must not fail a sweep.
1818fn prune_import_staging(store: &Path, dry_run: bool) {
1819    if dry_run {
1820        return;
1821    }
1822    let root = store.join(IMPORT_STAGING_DIR);
1823    let Ok(entries) = read_dir_or_empty(&root) else {
1824        return;
1825    };
1826    for entry in entries {
1827        let Ok(metadata) = entry.metadata() else {
1828            continue;
1829        };
1830        let abandoned = metadata
1831            .modified()
1832            .ok()
1833            .and_then(|modified| SystemTime::now().duration_since(modified).ok())
1834            .is_some_and(|age| age >= IMPORT_STAGING_RETENTION);
1835        if !abandoned {
1836            continue;
1837        }
1838        let path = entry.path();
1839        if !metadata.is_dir() {
1840            // A claim whose tree is already gone: reclaim it so claims cannot
1841            // pile up one per import that was killed after its tree went.
1842            if path
1843                .extension()
1844                .is_some_and(|extension| extension == "lock")
1845                && !claimed_staging_path(&path).exists()
1846            {
1847                let _ = std::fs::remove_file(&path);
1848            }
1849            continue;
1850        }
1851        // Age alone would be a guess. An import that outlives the retention
1852        // window -- a very large bundle on slow storage -- still holds its
1853        // lock, and taking its tree out from under it would fail a running
1854        // restore. A lock nobody holds is one the kernel released for a
1855        // process that is gone.
1856        let lock_path = import_staging_lock_path(&path);
1857        let Ok(mut lock) = fslock::LockFile::open(&lock_path) else {
1858            continue;
1859        };
1860        if !matches!(lock.try_lock(), Ok(true)) {
1861            continue;
1862        }
1863        if let Err(error) = std::fs::remove_dir_all(&path) {
1864            log::debug!(
1865                "could not remove abandoned import staging {}: {error}",
1866                path.display()
1867            );
1868            continue;
1869        }
1870        drop(lock);
1871        let _ = std::fs::remove_file(&lock_path);
1872    }
1873}
1874
1875/// The staging tree a claim describes.
1876fn claimed_staging_path(lock: &Path) -> PathBuf {
1877    lock.with_extension("")
1878}
1879
1880/// Where the liveness lock for one staging tree lives.
1881///
1882/// A sibling of the tree rather than a file inside it, so that removing the
1883/// tree does not remove the lock the remover is holding.
1884fn import_staging_lock_path(staging: &Path) -> PathBuf {
1885    let mut name = staging.as_os_str().to_os_string();
1886    name.push(".lock");
1887    PathBuf::from(name)
1888}
1889
1890/// One import's claim on its staging tree, released when the import ends.
1891///
1892/// The lock file is removed with the claim. `TempDir` takes the tree itself,
1893/// and a claim left behind would otherwise outlive every tree it described:
1894/// the sweep walks directories, so nothing would ever collect it.
1895struct StagingClaim {
1896    lock: Option<fslock::LockFile>,
1897    path: PathBuf,
1898}
1899
1900impl Drop for StagingClaim {
1901    fn drop(&mut self) {
1902        // Release before unlinking, so a sweep that already opened this file
1903        // sees an unlocked claim rather than waiting on one that is going away.
1904        drop(self.lock.take());
1905        let _ = std::fs::remove_file(&self.path);
1906    }
1907}
1908
1909/// Claim a staging tree for the lifetime of one import.
1910fn lock_import_staging(staging: &Path) -> Result<StagingClaim> {
1911    let path = import_staging_lock_path(staging);
1912    let mut lock = fslock::LockFile::open(&path)
1913        .wrap_err_with(|| format!("failed to open {}", path.display()))?;
1914    lock.lock()?;
1915    Ok(StagingClaim {
1916        lock: Some(lock),
1917        path,
1918    })
1919}
1920
1921#[derive(Debug, Default, PartialEq, Eq)]
1922struct SessionPrune {
1923    removed_streams: u64,
1924    removed_bytes: u64,
1925}
1926
1927fn prune_sessions(store: &Path, dry_run: bool) -> Result<SessionPrune> {
1928    let mut prune = SessionPrune::default();
1929    let ids = events::session_ids(store);
1930    let surplus = ids.len().saturating_sub(MAX_SESSIONS);
1931    for (index, id) in ids.iter().enumerate() {
1932        let paths = events::session_paths(store, id);
1933        let metadata = match std::fs::metadata(&paths.events) {
1934            Ok(metadata) => metadata,
1935            Err(_) => continue,
1936        };
1937        let stale = metadata
1938            .modified()
1939            .ok()
1940            .and_then(|at| SystemTime::now().duration_since(at).ok())
1941            .is_some_and(|age| age > SESSION_RETENTION);
1942        if index >= surplus && !stale {
1943            continue;
1944        }
1945        if events::session_is_live(store, id) {
1946            continue;
1947        }
1948        if dry_run {
1949            prune.removed_streams += 1;
1950            prune.removed_bytes += metadata.len();
1951            continue;
1952        }
1953        if matches!(remove(&paths.events)?, Removal::Removed) {
1954            prune.removed_streams += 1;
1955            prune.removed_bytes += metadata.len();
1956            let _ = std::fs::remove_file(&paths.lock);
1957        }
1958    }
1959    if !dry_run {
1960        for lock in events::orphaned_locks(store) {
1961            let _ = std::fs::remove_file(lock);
1962        }
1963    }
1964    Ok(prune)
1965}
1966
1967/// Sweep the store if `interval` has passed since the last attempt.
1968///
1969/// The stamp is written before the sweep, not after. Two builds finishing
1970/// together should cost one sweep between them, and a sweep that dies partway
1971/// should wait its turn like any other rather than retry on every build.
1972pub fn sweep_if_due(store: &Path, max_bytes: u64, interval: Duration) -> Result<Option<GcOutcome>> {
1973    if !claim_sweep(store, interval)? {
1974        return Ok(None);
1975    }
1976    gc(store, max_bytes).map(Some)
1977}
1978
1979/// Stamp a due sweep before its callers perform coordinated GC.
1980///
1981/// The sweep lock serializes claims, so updating the existing marker in place
1982/// is enough. In particular, it does not need a temporary file and rename:
1983/// claiming a sweep must still work when the disk has no room for one.
1984pub fn claim_sweep(store: &Path, interval: Duration) -> Result<bool> {
1985    let lock_path = store.join(SWEEP_LOCK);
1986    std::fs::create_dir_all(lock_path.parent().expect("sweep lock has a parent"))?;
1987    let mut lock = fslock::LockFile::open(&lock_path)?;
1988    lock.lock()?;
1989
1990    let stamp = store.join(SWEEP_STAMP);
1991    if sweep_stamp_is_fresh(&stamp, interval) {
1992        return Ok(false);
1993    }
1994    let stamp = std::fs::OpenOptions::new()
1995        .create(true)
1996        .write(true)
1997        .truncate(false)
1998        .open(&stamp)?;
1999    stamp.set_modified(SystemTime::now())?;
2000    Ok(true)
2001}
2002
2003/// Whether [`claim_sweep`] would stamp a sweep now.
2004///
2005/// A read of the stamp, no lock and no write: this answers a build that only
2006/// wants to know whether to start a collector, and may be answered yes to two
2007/// builds at once. The claim itself stays inside the collector, which is where
2008/// the lock and the stamp are, so of two collectors started together, one
2009/// sweeps and the other finds the stamp fresh and exits.
2010pub fn sweep_is_due(store: &Path, interval: Duration) -> bool {
2011    !sweep_stamp_is_fresh(&store.join(SWEEP_STAMP), interval)
2012}
2013
2014fn sweep_stamp_is_fresh(stamp: &Path, interval: Duration) -> bool {
2015    std::fs::metadata(stamp)
2016        .and_then(|metadata| metadata.modified())
2017        .ok()
2018        .and_then(|modified| modified.elapsed().ok())
2019        .is_some_and(|since| since < interval)
2020}
2021
2022/// What the checkout registry says about the identities in this store.
2023struct CheckoutScan {
2024    live_identities: BTreeSet<String>,
2025    live_records: u64,
2026    stale_records: Vec<PathBuf>,
2027}
2028
2029fn scan_checkouts(store: &Path) -> Result<CheckoutScan> {
2030    let root = store.join(CHECKOUTS_DIR);
2031    let mut scan = CheckoutScan {
2032        live_identities: BTreeSet::new(),
2033        live_records: 0,
2034        stale_records: Vec::new(),
2035    };
2036    let listing = match std::fs::read_dir(&root) {
2037        Ok(listing) => listing,
2038        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(scan),
2039        Err(error) => {
2040            return Err(error).wrap_err_with(|| format!("failed to read {}", root.display()));
2041        }
2042    };
2043    for entry in listing {
2044        let entry = entry?;
2045        let name = entry.file_name().to_string_lossy().into_owned();
2046        // Anything that is not a directory named for an identity was not written
2047        // by mbx. Both halves matter: `walk_files` tolerates only a missing
2048        // directory, so a plain file whose name happens to look like an identity
2049        // would fail the whole scan -- and that scan runs inside every sweep,
2050        // including the automatic one after a build.
2051        if !is_task_identity(&name) || !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
2052            continue;
2053        }
2054        for record in walk_files(&entry.path())? {
2055            match read_checkout_record(&record.path) {
2056                Some(checkout) if claim_is_live(store, &checkout) => {
2057                    scan.live_identities.insert(name.clone());
2058                    scan.live_records += 1;
2059                }
2060                Some(_) => scan.stale_records.push(record.path),
2061                // A record this build cannot read claims nothing, but it is not
2062                // evidence the checkout is gone either, so leave it alone.
2063                None => {}
2064            }
2065        }
2066    }
2067    Ok(scan)
2068}
2069
2070fn read_checkout_record(path: &Path) -> Option<CheckoutRecord> {
2071    let bytes = std::fs::read(path).ok()?;
2072    let record = serde_json::from_slice::<CheckoutRecord>(&bytes).ok()?;
2073    (record.version == CHECKOUT_RECORD_VERSION).then_some(record)
2074}
2075
2076/// Actions whose successful grouped export has not retired its receipts yet.
2077fn grouped_receipt_actions(store: &Path) -> Result<BTreeSet<CacheDigest>> {
2078    let root = store.join(BUILD_RECEIPTS_DIR).join("groups");
2079    Ok(walk_files(&root)?
2080        .into_iter()
2081        .filter_map(|entry| read_build_receipt(&entry.path))
2082        .flat_map(|receipt| receipt.predictions.into_iter())
2083        .map(|prediction| prediction.action)
2084        .collect())
2085}
2086
2087/// Whether a recorded claim still speaks for a checkout that is using this
2088/// store.
2089fn claim_is_live(store: &Path, record: &CheckoutRecord) -> bool {
2090    checkout_is_live_on(store, &record.workspace_root) && !claim_has_expired(record)
2091}
2092
2093fn claim_has_expired(record: &CheckoutRecord) -> bool {
2094    let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
2095        return false;
2096    };
2097    now.as_secs()
2098        .saturating_sub(record.updated_secs)
2099        .gt(&CHECKOUT_RETENTION.as_secs())
2100}
2101
2102/// Whether the checkout a record names is still on disk.
2103///
2104/// Absence is believed only when the checkout is definitely absent and its
2105/// nearest existing ancestor is on the same filesystem as the store. Walking
2106/// all the way to that ancestor matters for worktree managers and temporary
2107/// directories, which commonly remove a checkout together with one or more of
2108/// its otherwise-empty parents.
2109///
2110/// A different filesystem can be a mount whose contents are temporarily
2111/// unavailable, so uncertainty there remains live. Errors are treated the same
2112/// way: being wrong in that direction only delays collection; the other way
2113/// round throws away a warm cache someone is still using.
2114pub fn checkout_is_live_on(store: &Path, workspace_root: &Path) -> bool {
2115    if !matches!(workspace_root.try_exists(), Ok(false)) {
2116        return true;
2117    }
2118
2119    let Ok(store_metadata) = std::fs::metadata(store) else {
2120        return true;
2121    };
2122    for ancestor in workspace_root.ancestors().skip(1) {
2123        match std::fs::metadata(ancestor) {
2124            Ok(ancestor_metadata) => {
2125                return !same_filesystem(store, &store_metadata, ancestor, &ancestor_metadata);
2126            }
2127            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2128            Err(_) => return true,
2129        }
2130    }
2131    true
2132}
2133
2134/// Whether a checkout is still on disk, corroborating absence through its
2135/// immediate parent.
2136///
2137/// Prefer [`checkout_is_live_on`] when an existing store or managed-target
2138/// root is available to distinguish deletion from an unavailable filesystem.
2139pub fn checkout_is_live(workspace_root: &Path) -> bool {
2140    let Some(parent) = workspace_root.parent() else {
2141        return true;
2142    };
2143    checkout_is_live_on(parent, workspace_root)
2144}
2145
2146/// Whether two existing paths sit on the same filesystem, so a rename can
2147/// carry a directory from one to the other.
2148///
2149/// A path that cannot be inspected answers `false`: a move that cannot be
2150/// checked is not one to attempt.
2151pub fn same_filesystem_paths(a: &Path, b: &Path) -> bool {
2152    let (Ok(a_metadata), Ok(b_metadata)) = (std::fs::metadata(a), std::fs::metadata(b)) else {
2153        return false;
2154    };
2155    same_filesystem(a, &a_metadata, b, &b_metadata)
2156}
2157
2158#[cfg(unix)]
2159fn same_filesystem(
2160    _a_path: &Path,
2161    a: &std::fs::Metadata,
2162    _b_path: &Path,
2163    b: &std::fs::Metadata,
2164) -> bool {
2165    use std::os::unix::fs::MetadataExt as _;
2166    a.dev() == b.dev()
2167}
2168
2169#[cfg(windows)]
2170fn same_filesystem(
2171    a_path: &Path,
2172    _a: &std::fs::Metadata,
2173    b_path: &Path,
2174    _b: &std::fs::Metadata,
2175) -> bool {
2176    windows_volume_name(a_path).is_some_and(|volume| windows_volume_name(b_path) == Some(volume))
2177}
2178
2179#[cfg(windows)]
2180fn windows_volume_name(path: &Path) -> Option<Vec<u16>> {
2181    use std::os::windows::ffi::OsStrExt as _;
2182    use windows_sys::Win32::Storage::FileSystem::{
2183        GetVolumeNameForVolumeMountPointW, GetVolumePathNameW,
2184    };
2185
2186    let path = path
2187        .as_os_str()
2188        .encode_wide()
2189        .chain(Some(0))
2190        .collect::<Vec<_>>();
2191    // Both APIs document MAX_PATH-sized output buffers for volume paths and
2192    // names. Failure is uncertainty, which deliberately keeps the checkout
2193    // live.
2194    let mut mount = vec![0_u16; 261];
2195    // SAFETY: `path` is nul-terminated and `mount` is writable for the stated
2196    // number of UTF-16 code units.
2197    if unsafe { GetVolumePathNameW(path.as_ptr(), mount.as_mut_ptr(), mount.len() as u32) } == 0 {
2198        return None;
2199    }
2200    let mut volume = vec![0_u16; 261];
2201    // SAFETY: the successful call above left `mount` nul-terminated, and
2202    // `volume` is writable for the stated number of UTF-16 code units.
2203    if unsafe {
2204        GetVolumeNameForVolumeMountPointW(mount.as_ptr(), volume.as_mut_ptr(), volume.len() as u32)
2205    } == 0
2206    {
2207        return None;
2208    }
2209    volume.truncate(volume.iter().position(|unit| *unit == 0)?);
2210    Some(volume)
2211}
2212
2213#[cfg(not(any(unix, windows)))]
2214fn same_filesystem(
2215    _a_path: &Path,
2216    _a: &std::fs::Metadata,
2217    _b_path: &Path,
2218    _b: &std::fs::Metadata,
2219) -> bool {
2220    false
2221}
2222
2223/// CAS paths reachable from the builds that live checkouts still depend on.
2224///
2225/// Recursing into output trees is the point: the descriptor blobs are tiny and
2226/// the leaf artifacts are where the bytes are, so rooting that stopped at the
2227/// top level would protect nothing worth protecting. Every read here is
2228/// tolerant -- a blob that has already been evicted, or that no longer parses,
2229/// simply roots less, and rooting less can only mean collecting sooner.
2230fn rooted_objects(store: &Path, identities: &BTreeSet<String>) -> Result<HashSet<PathBuf>> {
2231    let mut actions = BTreeSet::new();
2232    for identity in identities {
2233        // One manifest this build cannot read is not worth abandoning a sweep
2234        // over. It roots nothing, which can only mean collecting sooner.
2235        let manifest_actions = match task_manifest_actions(store, identity) {
2236            Ok(actions) => actions,
2237            Err(error) => {
2238                log::debug!("could not read the manifest for {identity}: {error}");
2239                continue;
2240            }
2241        };
2242        actions.extend(manifest_actions);
2243    }
2244    Ok(rooted_action_objects(store, actions))
2245}
2246
2247/// Return every local CAS path needed to restore the supplied actions.
2248fn rooted_action_objects(
2249    store: &Path,
2250    actions: impl IntoIterator<Item = CacheDigest>,
2251) -> HashSet<PathBuf> {
2252    let cas = LocalCas::new(store);
2253    let mut rooted = HashSet::new();
2254    let mut visited = BTreeSet::new();
2255    for action in actions {
2256        let Some(result) = read_action_result(store, &action) else {
2257            continue;
2258        };
2259        root_digest(&cas, &mut rooted, &result.action);
2260        if let Some(metadata) = &result.metadata {
2261            root_digest(&cas, &mut rooted, metadata);
2262            // The metadata blob is a descriptor too: it names the captured
2263            // stdout and stderr, and a restore reads both. Stopping at the
2264            // descriptor would leave the diagnostics of every action
2265            // unrooted -- including the one empty blob that every silent
2266            // compilation shares, which is enough on its own to turn a
2267            // rooted hit back into a miss.
2268            if let Some(rustc) = read_rustc_metadata(&cas, metadata) {
2269                root_digest(&cas, &mut rooted, &rustc.stdout);
2270                root_digest(&cas, &mut rooted, &rustc.stderr);
2271            }
2272        }
2273        if let Some(output_root) = &result.output_root {
2274            root_digest(&cas, &mut rooted, output_root);
2275            root_tree(&cas, output_root, &mut rooted, &mut visited);
2276        }
2277    }
2278    rooted
2279}
2280
2281fn read_action_result(store: &Path, action: &CacheDigest) -> Option<RemoteActionResult> {
2282    let path = mbx_cache_core::LocalActionCache::new(store)
2283        .path_for(action)
2284        .ok()?;
2285    let bytes = std::fs::read(path).ok()?;
2286    serde_json::from_slice(&bytes).ok()
2287}
2288
2289fn root_tree(
2290    cas: &LocalCas,
2291    output_root: &CacheDigest,
2292    rooted: &mut HashSet<PathBuf>,
2293    visited: &mut BTreeSet<CacheDigest>,
2294) {
2295    let mut pending = vec![output_root.clone()];
2296    while let Some(digest) = pending.pop() {
2297        if !visited.insert(digest.clone()) {
2298            continue;
2299        }
2300        let Some(directory) = read_directory(cas, &digest) else {
2301            continue;
2302        };
2303        for file in &directory.files {
2304            root_digest(cas, rooted, &file.digest);
2305        }
2306        for child in &directory.directories {
2307            root_digest(cas, rooted, &child.digest);
2308            pending.push(child.digest.clone());
2309        }
2310    }
2311}
2312
2313fn read_rustc_metadata(cas: &LocalCas, digest: &CacheDigest) -> Option<RustcMetadata> {
2314    let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2315    serde_json::from_slice(&bytes).ok()
2316}
2317
2318fn read_directory(cas: &LocalCas, digest: &CacheDigest) -> Option<CacheDirectory> {
2319    let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2320    serde_json::from_slice(&bytes).ok()
2321}
2322
2323fn root_digest(cas: &LocalCas, rooted: &mut HashSet<PathBuf>, digest: &CacheDigest) {
2324    if let Ok(path) = cas.path_for(digest) {
2325        rooted.insert(path);
2326    }
2327}
2328
2329#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2330enum Removal {
2331    Removed,
2332    Missing,
2333    Blocked,
2334}
2335
2336fn remove(path: &Path) -> Result<Removal> {
2337    match std::fs::remove_file(path) {
2338        Ok(()) => Ok(Removal::Removed),
2339        // A concurrent build may have evicted or replaced it already.
2340        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Removal::Missing),
2341        // Windows refuses to unlink a file another process holds open, which is
2342        // what a concurrent build reading this blob looks like. Skipping it
2343        // leaves the store over budget until the next sweep; failing would
2344        // abandon the sweep and leave it over budget for longer.
2345        Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2346            log::debug!("could not evict {}: {error}", path.display());
2347            Ok(Removal::Blocked)
2348        }
2349        Err(error) => Err(error).wrap_err_with(|| format!("failed to evict {}", path.display())),
2350    }
2351}
2352
2353#[derive(Debug, Default, PartialEq, Eq)]
2354struct ObjectEvictions {
2355    removed_objects: u64,
2356    removed_bytes: u64,
2357    remaining_bytes: u64,
2358}
2359
2360/// Evict sorted objects without crossing the checkout-protection boundary
2361/// when an unrooted object cannot be removed.
2362fn evict_objects(
2363    objects: &[Entry],
2364    rooted: &HashSet<PathBuf>,
2365    mut live_bytes: u64,
2366    max_bytes: u64,
2367    mut remove_file: impl FnMut(&Path) -> Result<Removal>,
2368) -> Result<ObjectEvictions> {
2369    let mut outcome = ObjectEvictions::default();
2370    let mut blocked_unrooted = false;
2371    for entry in objects {
2372        if live_bytes <= max_bytes {
2373            break;
2374        }
2375        let is_rooted = rooted.contains(&entry.path);
2376        if is_rooted && blocked_unrooted {
2377            // A locked unrooted blob is temporarily part of the irreducible
2378            // store. Deleting a live checkout's artifacts in its place would
2379            // invert the protection ordering this collector promises.
2380            break;
2381        }
2382        match remove_file(&entry.path)? {
2383            Removal::Removed => {
2384                live_bytes = live_bytes.saturating_sub(entry.size);
2385                outcome.removed_objects += 1;
2386                outcome.removed_bytes += entry.size;
2387            }
2388            Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
2389            Removal::Blocked if !is_rooted => blocked_unrooted = true,
2390            Removal::Blocked => {}
2391        }
2392    }
2393    outcome.remaining_bytes = live_bytes;
2394    Ok(outcome)
2395}
2396
2397/// Whether an action result references an object the store no longer has.
2398///
2399/// The digests checked here are exactly the ones `LocalActionCache::store`
2400/// requires. If the sweep checked fewer, eviction could leave an entry that
2401/// cannot be republished -- `store` would reject the identical result for a
2402/// missing blob while the index still claimed to hold it.
2403///
2404/// Presence is all that is checked, not content. Verifying would re-hash a
2405/// large fraction of the store on every sweep, which a chore could afford and
2406/// an automatic sweep cannot; it would also refresh the access time of every
2407/// descriptor blob while leaving the artifacts alone, biasing the very
2408/// ordering this module depends on. A blob that is present but corrupt now
2409/// keeps its result and turns into a miss on restore, and both CAS write paths
2410/// republish over a corrupt blob rather than trusting it.
2411///
2412/// Only the top-level objects are checked; a result whose output tree lost a
2413/// nested object still restores as a miss, which is safe.
2414fn action_result_is_dangling(
2415    cas: &LocalCas,
2416    path: &Path,
2417    virtually_removed: &HashSet<PathBuf>,
2418) -> Result<bool> {
2419    let bytes = match std::fs::read(path) {
2420        Ok(bytes) => bytes,
2421        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
2422        Err(error) => {
2423            return Err(error).wrap_err_with(|| format!("failed to read {}", path.display()));
2424        }
2425    };
2426    // An unparseable result is already useless; the cache rejects it on read.
2427    let Ok(result) = serde_json::from_slice::<RemoteActionResult>(&bytes) else {
2428        return Ok(false);
2429    };
2430    for digest in [
2431        Some(&result.action),
2432        result.metadata.as_ref(),
2433        result.output_root.as_ref(),
2434    ]
2435    .into_iter()
2436    .flatten()
2437    {
2438        match cas.path_for(digest) {
2439            Ok(path) => {
2440                if virtually_removed.contains(&path) || !path.exists() {
2441                    return Ok(true);
2442                }
2443            }
2444            // A digest the CAS cannot even address is not one it can hold.
2445            Err(_) => return Ok(true),
2446        }
2447    }
2448    Ok(false)
2449}
2450
2451struct Entry {
2452    path: PathBuf,
2453    size: u64,
2454    used: SystemTime,
2455}
2456
2457fn walk_files(root: &Path) -> Result<Vec<Entry>> {
2458    let mut entries = Vec::new();
2459    let mut pending = vec![root.to_path_buf()];
2460    while let Some(directory) = pending.pop() {
2461        let listing = match std::fs::read_dir(&directory) {
2462            Ok(listing) => listing,
2463            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2464            Err(error) => {
2465                return Err(error)
2466                    .wrap_err_with(|| format!("failed to read {}", directory.display()));
2467            }
2468        };
2469        for entry in listing {
2470            let entry = entry?;
2471            let metadata = match entry.metadata() {
2472                Ok(metadata) => metadata,
2473                // A concurrent build may be publishing into the store.
2474                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2475                Err(error) => return Err(error.into()),
2476            };
2477            if metadata.is_dir() {
2478                pending.push(entry.path());
2479            } else if metadata.is_file() {
2480                let used = metadata
2481                    .accessed()
2482                    .or_else(|_| metadata.modified())
2483                    .unwrap_or(SystemTime::UNIX_EPOCH);
2484                entries.push(Entry {
2485                    path: entry.path(),
2486                    size: metadata.len(),
2487                    used,
2488                });
2489            }
2490        }
2491    }
2492    Ok(entries)
2493}
2494
2495fn read_dir_or_empty(root: &Path) -> Result<Vec<std::fs::DirEntry>> {
2496    match std::fs::read_dir(root) {
2497        Ok(entries) => entries
2498            .collect::<std::io::Result<Vec<_>>>()
2499            .map_err(Into::into),
2500        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
2501        Err(error) => Err(error).wrap_err_with(|| format!("failed to read {}", root.display())),
2502    }
2503}
2504
2505fn digest_from_path(path: &Path, action_result: bool) -> Option<CacheDigest> {
2506    let name = path.file_name()?.to_str()?;
2507    let name = if action_result {
2508        name.strip_suffix(".json")?
2509    } else {
2510        name
2511    };
2512    let (hash, size) = name.rsplit_once('-')?;
2513    let algorithm = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
2514    let digest = CacheDigest {
2515        algorithm,
2516        hash: hash.to_string(),
2517        size: size.parse().ok()?,
2518    };
2519    digest.validate().ok()?;
2520    Some(digest)
2521}
2522
2523fn addressed_digest(store: &Path, path: &Path, action_result: bool) -> Option<CacheDigest> {
2524    let digest = digest_from_path(path, action_result)?;
2525    let canonical = if action_result {
2526        mbx_cache_core::LocalActionCache::new(store)
2527            .path_for(&digest)
2528            .ok()?
2529    } else {
2530        LocalCas::new(store).path_for(&digest).ok()?
2531    };
2532    (canonical == path).then_some(digest)
2533}
2534
2535fn cached_tree_bytes(cache: &mut BTreeMap<PathBuf, u64>, root: &Path) -> u64 {
2536    *cache
2537        .entry(root.to_path_buf())
2538        .or_insert_with(|| tree_bytes(root))
2539}
2540
2541fn tree_bytes(root: &Path) -> u64 {
2542    let Ok(root_metadata) = std::fs::metadata(root) else {
2543        return 0;
2544    };
2545    if root_metadata.is_file() {
2546        return root_metadata.len();
2547    }
2548    let Ok(entries) = std::fs::read_dir(root) else {
2549        return 0;
2550    };
2551    let mut bytes = 0_u64;
2552    let mut pending = entries
2553        .filter_map(std::result::Result::ok)
2554        .map(|entry| entry.path())
2555        .collect::<Vec<_>>();
2556    while let Some(path) = pending.pop() {
2557        let Ok(metadata) = std::fs::symlink_metadata(&path) else {
2558            continue;
2559        };
2560        if metadata.is_file() {
2561            bytes = bytes.saturating_add(metadata.len());
2562        } else if metadata.is_dir()
2563            && let Ok(entries) = std::fs::read_dir(path)
2564        {
2565            pending.extend(
2566                entries
2567                    .filter_map(std::result::Result::ok)
2568                    .map(|entry| entry.path()),
2569            );
2570        }
2571    }
2572    bytes
2573}
2574
2575#[cfg(test)]
2576#[path = "store_tests.rs"]
2577mod tests;
2578
2579/// [`write_atomic`] without the sync: for records every build rewrites and
2580/// that a torn or missing file only makes one build older.
2581fn write_advisory(path: &Path, contents: &[u8]) -> Result<()> {
2582    let parent = path
2583        .parent()
2584        .filter(|parent| !parent.as_os_str().is_empty())
2585        .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
2586    std::fs::create_dir_all(parent)
2587        .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
2588    let mut temporary = tempfile::Builder::new()
2589        .prefix(".mbx-")
2590        .tempfile_in(parent)?;
2591    use std::io::Write as _;
2592    temporary.write_all(contents)?;
2593    temporary
2594        .persist(path)
2595        .map_err(|error| error.error)
2596        .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
2597    Ok(())
2598}
2599
2600fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
2601    let parent = path
2602        .parent()
2603        .filter(|parent| !parent.as_os_str().is_empty())
2604        .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
2605    std::fs::create_dir_all(parent)
2606        .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
2607    let mut temporary = tempfile::Builder::new()
2608        .prefix(".mbx-")
2609        .tempfile_in(parent)?;
2610    use std::io::Write as _;
2611    temporary.write_all(contents)?;
2612    temporary.as_file_mut().sync_all()?;
2613    temporary
2614        .persist(path)
2615        .map_err(|error| error.error)
2616        .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
2617    Ok(())
2618}