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