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/// Stamp a due sweep before its callers perform coordinated GC.
1929///
1930/// The sweep lock serializes claims, so updating the existing marker in place
1931/// is enough. In particular, it does not need a temporary file and rename:
1932/// claiming a sweep must still work when the disk has no room for one.
1933pub fn claim_sweep(store: &Path, interval: Duration) -> Result<bool> {
1934    let lock_path = store.join(SWEEP_LOCK);
1935    std::fs::create_dir_all(lock_path.parent().expect("sweep lock has a parent"))?;
1936    let mut lock = fslock::LockFile::open(&lock_path)?;
1937    lock.lock()?;
1938
1939    let stamp = store.join(SWEEP_STAMP);
1940    if sweep_stamp_is_fresh(&stamp, interval) {
1941        return Ok(false);
1942    }
1943    let stamp = std::fs::OpenOptions::new()
1944        .create(true)
1945        .write(true)
1946        .truncate(false)
1947        .open(&stamp)?;
1948    stamp.set_modified(SystemTime::now())?;
1949    Ok(true)
1950}
1951
1952/// Whether [`claim_sweep`] would stamp a sweep now.
1953///
1954/// A read of the stamp, no lock and no write: this answers a build that only
1955/// wants to know whether to start a collector, and may be answered yes to two
1956/// builds at once. The claim itself stays inside the collector, which is where
1957/// the lock and the stamp are, so of two collectors started together, one
1958/// sweeps and the other finds the stamp fresh and exits.
1959pub fn sweep_is_due(store: &Path, interval: Duration) -> bool {
1960    !sweep_stamp_is_fresh(&store.join(SWEEP_STAMP), interval)
1961}
1962
1963fn sweep_stamp_is_fresh(stamp: &Path, interval: Duration) -> bool {
1964    std::fs::metadata(stamp)
1965        .and_then(|metadata| metadata.modified())
1966        .ok()
1967        .and_then(|modified| modified.elapsed().ok())
1968        .is_some_and(|since| since < interval)
1969}
1970
1971/// What the checkout registry says about the identities in this store.
1972struct CheckoutScan {
1973    live_identities: BTreeSet<String>,
1974    live_records: u64,
1975    stale_records: Vec<PathBuf>,
1976}
1977
1978fn scan_checkouts(store: &Path) -> Result<CheckoutScan> {
1979    let root = store.join(CHECKOUTS_DIR);
1980    let mut scan = CheckoutScan {
1981        live_identities: BTreeSet::new(),
1982        live_records: 0,
1983        stale_records: Vec::new(),
1984    };
1985    let listing = match std::fs::read_dir(&root) {
1986        Ok(listing) => listing,
1987        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(scan),
1988        Err(error) => {
1989            return Err(error).wrap_err_with(|| format!("failed to read {}", root.display()));
1990        }
1991    };
1992    for entry in listing {
1993        let entry = entry?;
1994        let name = entry.file_name().to_string_lossy().into_owned();
1995        // Anything that is not a directory named for an identity was not written
1996        // by mbx. Both halves matter: `walk_files` tolerates only a missing
1997        // directory, so a plain file whose name happens to look like an identity
1998        // would fail the whole scan -- and that scan runs inside every sweep,
1999        // including the automatic one after a build.
2000        if !is_task_identity(&name) || !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
2001            continue;
2002        }
2003        for record in walk_files(&entry.path())? {
2004            match read_checkout_record(&record.path) {
2005                Some(checkout) if claim_is_live(store, &checkout) => {
2006                    scan.live_identities.insert(name.clone());
2007                    scan.live_records += 1;
2008                }
2009                Some(_) => scan.stale_records.push(record.path),
2010                // A record this build cannot read claims nothing, but it is not
2011                // evidence the checkout is gone either, so leave it alone.
2012                None => {}
2013            }
2014        }
2015    }
2016    Ok(scan)
2017}
2018
2019fn read_checkout_record(path: &Path) -> Option<CheckoutRecord> {
2020    let bytes = std::fs::read(path).ok()?;
2021    let record = serde_json::from_slice::<CheckoutRecord>(&bytes).ok()?;
2022    (record.version == CHECKOUT_RECORD_VERSION).then_some(record)
2023}
2024
2025/// Actions whose successful grouped export has not retired its receipts yet.
2026fn grouped_receipt_actions(store: &Path) -> Result<BTreeSet<CacheDigest>> {
2027    let root = store.join(BUILD_RECEIPTS_DIR).join("groups");
2028    Ok(walk_files(&root)?
2029        .into_iter()
2030        .filter_map(|entry| read_build_receipt(&entry.path))
2031        .flat_map(|receipt| receipt.predictions.into_iter())
2032        .map(|prediction| prediction.action)
2033        .collect())
2034}
2035
2036/// Whether a recorded claim still speaks for a checkout that is using this
2037/// store.
2038fn claim_is_live(store: &Path, record: &CheckoutRecord) -> bool {
2039    checkout_is_live_on(store, &record.workspace_root) && !claim_has_expired(record)
2040}
2041
2042fn claim_has_expired(record: &CheckoutRecord) -> bool {
2043    let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
2044        return false;
2045    };
2046    now.as_secs()
2047        .saturating_sub(record.updated_secs)
2048        .gt(&CHECKOUT_RETENTION.as_secs())
2049}
2050
2051/// Whether the checkout a record names is still on disk.
2052///
2053/// Absence is believed only when the checkout is definitely absent and its
2054/// nearest existing ancestor is on the same filesystem as the store. Walking
2055/// all the way to that ancestor matters for worktree managers and temporary
2056/// directories, which commonly remove a checkout together with one or more of
2057/// its otherwise-empty parents.
2058///
2059/// A different filesystem can be a mount whose contents are temporarily
2060/// unavailable, so uncertainty there remains live. Errors are treated the same
2061/// way: being wrong in that direction only delays collection; the other way
2062/// round throws away a warm cache someone is still using.
2063pub fn checkout_is_live_on(store: &Path, workspace_root: &Path) -> bool {
2064    if !matches!(workspace_root.try_exists(), Ok(false)) {
2065        return true;
2066    }
2067
2068    let Ok(store_metadata) = std::fs::metadata(store) else {
2069        return true;
2070    };
2071    for ancestor in workspace_root.ancestors().skip(1) {
2072        match std::fs::metadata(ancestor) {
2073            Ok(ancestor_metadata) => {
2074                return !same_filesystem(store, &store_metadata, ancestor, &ancestor_metadata);
2075            }
2076            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2077            Err(_) => return true,
2078        }
2079    }
2080    true
2081}
2082
2083/// Whether a checkout is still on disk, corroborating absence through its
2084/// immediate parent.
2085///
2086/// Prefer [`checkout_is_live_on`] when an existing store or managed-target
2087/// root is available to distinguish deletion from an unavailable filesystem.
2088pub fn checkout_is_live(workspace_root: &Path) -> bool {
2089    let Some(parent) = workspace_root.parent() else {
2090        return true;
2091    };
2092    checkout_is_live_on(parent, workspace_root)
2093}
2094
2095/// Whether two existing paths sit on the same filesystem, so a rename can
2096/// carry a directory from one to the other.
2097///
2098/// A path that cannot be inspected answers `false`: a move that cannot be
2099/// checked is not one to attempt.
2100pub fn same_filesystem_paths(a: &Path, b: &Path) -> bool {
2101    let (Ok(a_metadata), Ok(b_metadata)) = (std::fs::metadata(a), std::fs::metadata(b)) else {
2102        return false;
2103    };
2104    same_filesystem(a, &a_metadata, b, &b_metadata)
2105}
2106
2107#[cfg(unix)]
2108fn same_filesystem(
2109    _a_path: &Path,
2110    a: &std::fs::Metadata,
2111    _b_path: &Path,
2112    b: &std::fs::Metadata,
2113) -> bool {
2114    use std::os::unix::fs::MetadataExt as _;
2115    a.dev() == b.dev()
2116}
2117
2118#[cfg(windows)]
2119fn same_filesystem(
2120    a_path: &Path,
2121    _a: &std::fs::Metadata,
2122    b_path: &Path,
2123    _b: &std::fs::Metadata,
2124) -> bool {
2125    windows_volume_name(a_path).is_some_and(|volume| windows_volume_name(b_path) == Some(volume))
2126}
2127
2128#[cfg(windows)]
2129fn windows_volume_name(path: &Path) -> Option<Vec<u16>> {
2130    use std::os::windows::ffi::OsStrExt as _;
2131    use windows_sys::Win32::Storage::FileSystem::{
2132        GetVolumeNameForVolumeMountPointW, GetVolumePathNameW,
2133    };
2134
2135    let path = path
2136        .as_os_str()
2137        .encode_wide()
2138        .chain(Some(0))
2139        .collect::<Vec<_>>();
2140    // Both APIs document MAX_PATH-sized output buffers for volume paths and
2141    // names. Failure is uncertainty, which deliberately keeps the checkout
2142    // live.
2143    let mut mount = vec![0_u16; 261];
2144    // SAFETY: `path` is nul-terminated and `mount` is writable for the stated
2145    // number of UTF-16 code units.
2146    if unsafe { GetVolumePathNameW(path.as_ptr(), mount.as_mut_ptr(), mount.len() as u32) } == 0 {
2147        return None;
2148    }
2149    let mut volume = vec![0_u16; 261];
2150    // SAFETY: the successful call above left `mount` nul-terminated, and
2151    // `volume` is writable for the stated number of UTF-16 code units.
2152    if unsafe {
2153        GetVolumeNameForVolumeMountPointW(mount.as_ptr(), volume.as_mut_ptr(), volume.len() as u32)
2154    } == 0
2155    {
2156        return None;
2157    }
2158    volume.truncate(volume.iter().position(|unit| *unit == 0)?);
2159    Some(volume)
2160}
2161
2162#[cfg(not(any(unix, windows)))]
2163fn same_filesystem(
2164    _a_path: &Path,
2165    _a: &std::fs::Metadata,
2166    _b_path: &Path,
2167    _b: &std::fs::Metadata,
2168) -> bool {
2169    false
2170}
2171
2172/// CAS paths reachable from the builds that live checkouts still depend on.
2173///
2174/// Recursing into output trees is the point: the descriptor blobs are tiny and
2175/// the leaf artifacts are where the bytes are, so rooting that stopped at the
2176/// top level would protect nothing worth protecting. Every read here is
2177/// tolerant -- a blob that has already been evicted, or that no longer parses,
2178/// simply roots less, and rooting less can only mean collecting sooner.
2179fn rooted_objects(store: &Path, identities: &BTreeSet<String>) -> Result<HashSet<PathBuf>> {
2180    let mut actions = BTreeSet::new();
2181    for identity in identities {
2182        // One manifest this build cannot read is not worth abandoning a sweep
2183        // over. It roots nothing, which can only mean collecting sooner.
2184        let manifest_actions = match task_manifest_actions(store, identity) {
2185            Ok(actions) => actions,
2186            Err(error) => {
2187                log::debug!("could not read the manifest for {identity}: {error}");
2188                continue;
2189            }
2190        };
2191        actions.extend(manifest_actions);
2192    }
2193    Ok(rooted_action_objects(store, actions))
2194}
2195
2196/// Return every local CAS path needed to restore the supplied actions.
2197fn rooted_action_objects(
2198    store: &Path,
2199    actions: impl IntoIterator<Item = CacheDigest>,
2200) -> HashSet<PathBuf> {
2201    let cas = LocalCas::new(store);
2202    let mut rooted = HashSet::new();
2203    let mut visited = BTreeSet::new();
2204    for action in actions {
2205        let Some(result) = read_action_result(store, &action) else {
2206            continue;
2207        };
2208        root_digest(&cas, &mut rooted, &result.action);
2209        if let Some(metadata) = &result.metadata {
2210            root_digest(&cas, &mut rooted, metadata);
2211            // The metadata blob is a descriptor too: it names the captured
2212            // stdout and stderr, and a restore reads both. Stopping at the
2213            // descriptor would leave the diagnostics of every action
2214            // unrooted -- including the one empty blob that every silent
2215            // compilation shares, which is enough on its own to turn a
2216            // rooted hit back into a miss.
2217            if let Some(rustc) = read_rustc_metadata(&cas, metadata) {
2218                root_digest(&cas, &mut rooted, &rustc.stdout);
2219                root_digest(&cas, &mut rooted, &rustc.stderr);
2220            }
2221        }
2222        if let Some(output_root) = &result.output_root {
2223            root_digest(&cas, &mut rooted, output_root);
2224            root_tree(&cas, output_root, &mut rooted, &mut visited);
2225        }
2226    }
2227    rooted
2228}
2229
2230fn read_action_result(store: &Path, action: &CacheDigest) -> Option<RemoteActionResult> {
2231    let path = mbx_cache_core::LocalActionCache::new(store)
2232        .path_for(action)
2233        .ok()?;
2234    let bytes = std::fs::read(path).ok()?;
2235    serde_json::from_slice(&bytes).ok()
2236}
2237
2238fn root_tree(
2239    cas: &LocalCas,
2240    output_root: &CacheDigest,
2241    rooted: &mut HashSet<PathBuf>,
2242    visited: &mut BTreeSet<CacheDigest>,
2243) {
2244    let mut pending = vec![output_root.clone()];
2245    while let Some(digest) = pending.pop() {
2246        if !visited.insert(digest.clone()) {
2247            continue;
2248        }
2249        let Some(directory) = read_directory(cas, &digest) else {
2250            continue;
2251        };
2252        for file in &directory.files {
2253            root_digest(cas, rooted, &file.digest);
2254        }
2255        for child in &directory.directories {
2256            root_digest(cas, rooted, &child.digest);
2257            pending.push(child.digest.clone());
2258        }
2259    }
2260}
2261
2262fn read_rustc_metadata(cas: &LocalCas, digest: &CacheDigest) -> Option<RustcMetadata> {
2263    let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2264    serde_json::from_slice(&bytes).ok()
2265}
2266
2267fn read_directory(cas: &LocalCas, digest: &CacheDigest) -> Option<CacheDirectory> {
2268    let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2269    serde_json::from_slice(&bytes).ok()
2270}
2271
2272fn root_digest(cas: &LocalCas, rooted: &mut HashSet<PathBuf>, digest: &CacheDigest) {
2273    if let Ok(path) = cas.path_for(digest) {
2274        rooted.insert(path);
2275    }
2276}
2277
2278#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2279enum Removal {
2280    Removed,
2281    Missing,
2282    Blocked,
2283}
2284
2285fn remove(path: &Path) -> Result<Removal> {
2286    match std::fs::remove_file(path) {
2287        Ok(()) => Ok(Removal::Removed),
2288        // A concurrent build may have evicted or replaced it already.
2289        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Removal::Missing),
2290        // Windows refuses to unlink a file another process holds open, which is
2291        // what a concurrent build reading this blob looks like. Skipping it
2292        // leaves the store over budget until the next sweep; failing would
2293        // abandon the sweep and leave it over budget for longer.
2294        Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2295            log::debug!("could not evict {}: {error}", path.display());
2296            Ok(Removal::Blocked)
2297        }
2298        Err(error) => Err(error).wrap_err_with(|| format!("failed to evict {}", path.display())),
2299    }
2300}
2301
2302#[derive(Debug, Default, PartialEq, Eq)]
2303struct ObjectEvictions {
2304    removed_objects: u64,
2305    removed_bytes: u64,
2306    remaining_bytes: u64,
2307}
2308
2309/// Evict sorted objects without crossing the checkout-protection boundary
2310/// when an unrooted object cannot be removed.
2311fn evict_objects(
2312    objects: &[Entry],
2313    rooted: &HashSet<PathBuf>,
2314    mut live_bytes: u64,
2315    max_bytes: u64,
2316    mut remove_file: impl FnMut(&Path) -> Result<Removal>,
2317) -> Result<ObjectEvictions> {
2318    let mut outcome = ObjectEvictions::default();
2319    let mut blocked_unrooted = false;
2320    for entry in objects {
2321        if live_bytes <= max_bytes {
2322            break;
2323        }
2324        let is_rooted = rooted.contains(&entry.path);
2325        if is_rooted && blocked_unrooted {
2326            // A locked unrooted blob is temporarily part of the irreducible
2327            // store. Deleting a live checkout's artifacts in its place would
2328            // invert the protection ordering this collector promises.
2329            break;
2330        }
2331        match remove_file(&entry.path)? {
2332            Removal::Removed => {
2333                live_bytes = live_bytes.saturating_sub(entry.size);
2334                outcome.removed_objects += 1;
2335                outcome.removed_bytes += entry.size;
2336            }
2337            Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
2338            Removal::Blocked if !is_rooted => blocked_unrooted = true,
2339            Removal::Blocked => {}
2340        }
2341    }
2342    outcome.remaining_bytes = live_bytes;
2343    Ok(outcome)
2344}
2345
2346/// Whether an action result references an object the store no longer has.
2347///
2348/// The digests checked here are exactly the ones `LocalActionCache::store`
2349/// requires. If the sweep checked fewer, eviction could leave an entry that
2350/// cannot be republished -- `store` would reject the identical result for a
2351/// missing blob while the index still claimed to hold it.
2352///
2353/// Presence is all that is checked, not content. Verifying would re-hash a
2354/// large fraction of the store on every sweep, which a chore could afford and
2355/// an automatic sweep cannot; it would also refresh the access time of every
2356/// descriptor blob while leaving the artifacts alone, biasing the very
2357/// ordering this module depends on. A blob that is present but corrupt now
2358/// keeps its result and turns into a miss on restore, and both CAS write paths
2359/// republish over a corrupt blob rather than trusting it.
2360///
2361/// Only the top-level objects are checked; a result whose output tree lost a
2362/// nested object still restores as a miss, which is safe.
2363fn action_result_is_dangling(
2364    cas: &LocalCas,
2365    path: &Path,
2366    virtually_removed: &HashSet<PathBuf>,
2367) -> Result<bool> {
2368    let bytes = match std::fs::read(path) {
2369        Ok(bytes) => bytes,
2370        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
2371        Err(error) => {
2372            return Err(error).wrap_err_with(|| format!("failed to read {}", path.display()));
2373        }
2374    };
2375    // An unparseable result is already useless; the cache rejects it on read.
2376    let Ok(result) = serde_json::from_slice::<RemoteActionResult>(&bytes) else {
2377        return Ok(false);
2378    };
2379    for digest in [
2380        Some(&result.action),
2381        result.metadata.as_ref(),
2382        result.output_root.as_ref(),
2383    ]
2384    .into_iter()
2385    .flatten()
2386    {
2387        match cas.path_for(digest) {
2388            Ok(path) => {
2389                if virtually_removed.contains(&path) || !path.exists() {
2390                    return Ok(true);
2391                }
2392            }
2393            // A digest the CAS cannot even address is not one it can hold.
2394            Err(_) => return Ok(true),
2395        }
2396    }
2397    Ok(false)
2398}
2399
2400struct Entry {
2401    path: PathBuf,
2402    size: u64,
2403    used: SystemTime,
2404}
2405
2406fn walk_files(root: &Path) -> Result<Vec<Entry>> {
2407    let mut entries = Vec::new();
2408    let mut pending = vec![root.to_path_buf()];
2409    while let Some(directory) = pending.pop() {
2410        let listing = match std::fs::read_dir(&directory) {
2411            Ok(listing) => listing,
2412            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2413            Err(error) => {
2414                return Err(error)
2415                    .wrap_err_with(|| format!("failed to read {}", directory.display()));
2416            }
2417        };
2418        for entry in listing {
2419            let entry = entry?;
2420            let metadata = match entry.metadata() {
2421                Ok(metadata) => metadata,
2422                // A concurrent build may be publishing into the store.
2423                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2424                Err(error) => return Err(error.into()),
2425            };
2426            if metadata.is_dir() {
2427                pending.push(entry.path());
2428            } else if metadata.is_file() {
2429                let used = metadata
2430                    .accessed()
2431                    .or_else(|_| metadata.modified())
2432                    .unwrap_or(SystemTime::UNIX_EPOCH);
2433                entries.push(Entry {
2434                    path: entry.path(),
2435                    size: metadata.len(),
2436                    used,
2437                });
2438            }
2439        }
2440    }
2441    Ok(entries)
2442}
2443
2444fn read_dir_or_empty(root: &Path) -> Result<Vec<std::fs::DirEntry>> {
2445    match std::fs::read_dir(root) {
2446        Ok(entries) => entries
2447            .collect::<std::io::Result<Vec<_>>>()
2448            .map_err(Into::into),
2449        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
2450        Err(error) => Err(error).wrap_err_with(|| format!("failed to read {}", root.display())),
2451    }
2452}
2453
2454fn digest_from_path(path: &Path, action_result: bool) -> Option<CacheDigest> {
2455    let name = path.file_name()?.to_str()?;
2456    let name = if action_result {
2457        name.strip_suffix(".json")?
2458    } else {
2459        name
2460    };
2461    let (hash, size) = name.rsplit_once('-')?;
2462    let algorithm = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
2463    let digest = CacheDigest {
2464        algorithm,
2465        hash: hash.to_string(),
2466        size: size.parse().ok()?,
2467    };
2468    digest.validate().ok()?;
2469    Some(digest)
2470}
2471
2472fn addressed_digest(store: &Path, path: &Path, action_result: bool) -> Option<CacheDigest> {
2473    let digest = digest_from_path(path, action_result)?;
2474    let canonical = if action_result {
2475        mbx_cache_core::LocalActionCache::new(store)
2476            .path_for(&digest)
2477            .ok()?
2478    } else {
2479        LocalCas::new(store).path_for(&digest).ok()?
2480    };
2481    (canonical == path).then_some(digest)
2482}
2483
2484fn cached_tree_bytes(cache: &mut BTreeMap<PathBuf, u64>, root: &Path) -> u64 {
2485    *cache
2486        .entry(root.to_path_buf())
2487        .or_insert_with(|| tree_bytes(root))
2488}
2489
2490fn tree_bytes(root: &Path) -> u64 {
2491    let Ok(root_metadata) = std::fs::metadata(root) else {
2492        return 0;
2493    };
2494    if root_metadata.is_file() {
2495        return root_metadata.len();
2496    }
2497    let Ok(entries) = std::fs::read_dir(root) else {
2498        return 0;
2499    };
2500    let mut bytes = 0_u64;
2501    let mut pending = entries
2502        .filter_map(std::result::Result::ok)
2503        .map(|entry| entry.path())
2504        .collect::<Vec<_>>();
2505    while let Some(path) = pending.pop() {
2506        let Ok(metadata) = std::fs::symlink_metadata(&path) else {
2507            continue;
2508        };
2509        if metadata.is_file() {
2510            bytes = bytes.saturating_add(metadata.len());
2511        } else if metadata.is_dir()
2512            && let Ok(entries) = std::fs::read_dir(path)
2513        {
2514            pending.extend(
2515                entries
2516                    .filter_map(std::result::Result::ok)
2517                    .map(|entry| entry.path()),
2518            );
2519        }
2520    }
2521    bytes
2522}
2523
2524#[cfg(test)]
2525#[path = "store_tests.rs"]
2526mod tests;
2527
2528/// [`write_atomic`] without the sync: for records every build rewrites and
2529/// that a torn or missing file only makes one build older.
2530fn write_advisory(path: &Path, contents: &[u8]) -> Result<()> {
2531    let parent = path
2532        .parent()
2533        .filter(|parent| !parent.as_os_str().is_empty())
2534        .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
2535    std::fs::create_dir_all(parent)
2536        .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
2537    let mut temporary = tempfile::Builder::new()
2538        .prefix(".mbx-")
2539        .tempfile_in(parent)?;
2540    use std::io::Write as _;
2541    temporary.write_all(contents)?;
2542    temporary
2543        .persist(path)
2544        .map_err(|error| error.error)
2545        .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
2546    Ok(())
2547}
2548
2549fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
2550    let parent = path
2551        .parent()
2552        .filter(|parent| !parent.as_os_str().is_empty())
2553        .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
2554    std::fs::create_dir_all(parent)
2555        .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
2556    let mut temporary = tempfile::Builder::new()
2557        .prefix(".mbx-")
2558        .tempfile_in(parent)?;
2559    use std::io::Write as _;
2560    temporary.write_all(contents)?;
2561    temporary.as_file_mut().sync_all()?;
2562    temporary
2563        .persist(path)
2564        .map_err(|error| error.error)
2565        .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
2566    Ok(())
2567}