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; receipts are small, checkout records expire with their checkout
9//! claims, a manifest goes once no live checkout claims its identity, and
10//! session streams are bounded by age and count because they are history
11//! rather than cache content.
12
13mod events;
14
15use eyre::{Context, Result};
16use mbx_cache_core::{
17    ActionPrediction, ActionSlot, CacheDigest, CacheDirectory, LocalCas, RemoteActionResult,
18    RustcMetadata, TaskActionManifest, is_task_identity, merge_task_action_predictions,
19    task_manifest_actions,
20};
21use serde::{Deserialize, Serialize};
22use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
23use std::path::{Component, Path, PathBuf};
24use std::sync::atomic::{AtomicUsize, Ordering};
25use std::sync::{Arc, Mutex};
26use std::time::{Duration, SystemTime, UNIX_EPOCH};
27
28const CAS_DIR: &str = "cas/v1";
29const ACTION_RESULTS_DIR: &str = "action-results/v1";
30const TASK_MANIFESTS_DIR: &str = "task-manifests/v1";
31const CHECKOUTS_DIR: &str = "checkouts/v1";
32const SWEEP_STAMP: &str = "gc/v1/last-sweep";
33const SWEEP_LOCK: &str = "gc/v1/sweep.lock";
34const CHECKOUT_RECORD_VERSION: u8 = 1;
35const BUILD_RECEIPTS_DIR: &str = "build-receipts/v1";
36const BUILD_RECEIPT_VERSION: u8 = 1;
37const IMPORT_STAGING_DIR: &str = "import-staging";
38const EXPORT_MANIFEST: &str = "mbx-cache-export-v1.json";
39const EXPORT_VERSION: u8 = 2;
40const LEGACY_EXPORT_VERSION: u8 = 1;
41
42const IMPORT_STAGING_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
43const SESSION_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
44
45/// How long an unclaimed task manifest or its lock must sit untouched before a
46/// sweep removes it.
47///
48/// A manifest is claimed through the checkout record of the build that uses it,
49/// and that record is written when the build starts. An archive import or a
50/// build from another embedder can still leave a manifest before any record
51/// names it, and the collector has to leave that one for the build it was
52/// written for.
53const TASK_MANIFEST_GRACE: Duration = Duration::from_secs(24 * 60 * 60);
54const MAX_SESSIONS: usize = 256;
55
56/// How long a checkout's claim outlives the last build that renewed it.
57///
58/// Existence alone is not enough to keep a claim alive. An identity covers one
59/// exact command line against one exact lockfile, so a checkout that lives for
60/// years accumulates an identity per lockfile it ever had -- and every one of
61/// them would go on rooting the actions of a build nobody will run again.
62/// Without this bound the rooted set only grows, until everything is rooted and
63/// the ordering means nothing. A build renews the claims it uses, so anything
64/// this stale belongs to a command that has moved on.
65const CHECKOUT_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60);
66
67#[derive(Debug, Default, PartialEq, Eq)]
68pub struct StoreStats {
69    pub objects: u64,
70    pub object_bytes: u64,
71    pub action_results: u64,
72    pub action_result_bytes: u64,
73    pub live_checkouts: u64,
74    /// Claims that root nothing any more: a checkout that is gone, or one that
75    /// is still there but has not renewed this claim inside the retention
76    /// window. Reported as stale rather than gone because those are different
77    /// things and only one of them means the directory is missing.
78    pub stale_checkouts: u64,
79}
80
81impl StoreStats {
82    pub fn total_bytes(&self) -> u64 {
83        self.object_bytes.saturating_add(self.action_result_bytes)
84    }
85}
86
87#[derive(Debug, Default, PartialEq, Eq)]
88pub struct GcOutcome {
89    pub removed_objects: u64,
90    pub removed_action_results: u64,
91    pub removed_checkout_records: u64,
92    /// Prediction manifests whose identity no live checkout claims. Their bytes
93    /// are counted in `removed_bytes` but not in `remaining_bytes`, because
94    /// manifests are not cache content.
95    pub removed_task_manifests: u64,
96    /// Event streams dropped on age or count. These bytes are not included in
97    /// `remaining_bytes` because session history is not cache content.
98    pub removed_session_streams: u64,
99    pub removed_bytes: u64,
100    pub remaining_bytes: u64,
101}
102
103#[derive(Debug, PartialEq, Eq)]
104pub struct ProjectUsage {
105    pub workspace_root: PathBuf,
106    pub identities: u64,
107    pub action_bytes: u64,
108    pub target_bytes: u64,
109    pub live: bool,
110}
111
112#[derive(Debug, PartialEq, Eq)]
113pub struct LargestEntry {
114    pub kind: &'static str,
115    pub path: PathBuf,
116    pub bytes: u64,
117}
118
119#[derive(Debug, Default, PartialEq, Eq)]
120pub struct VerifyOutcome {
121    pub checked_objects: u64,
122    pub checked_action_results: u64,
123    pub problems: Vec<PathBuf>,
124}
125
126#[derive(Debug, Default, PartialEq, Eq)]
127pub struct RemoveProjectOutcome {
128    pub removed_checkout_records: u64,
129}
130
131#[derive(Debug, Default, PartialEq, Eq)]
132pub struct TransferOutcome {
133    pub actions: u64,
134    pub objects: u64,
135    pub bytes: u64,
136}
137
138/// A cache import together with named higher-level CAS roots it carried.
139#[derive(Debug)]
140#[non_exhaustive]
141pub struct ImportOutcome {
142    pub transfer: TransferOutcome,
143    pub attachments: BTreeMap<String, CacheDigest>,
144    /// The predictions the bundle recorded for each build command.
145    pub tasks: Vec<TaskActionManifest>,
146}
147
148/// Extra CAS roots carried by a cache export for a higher-level transport.
149#[derive(Debug, Default)]
150pub struct ExportAdditions {
151    /// Stable names by which the importer can discover selected objects.
152    pub attachments: BTreeMap<String, CacheDigest>,
153    /// Every object the attachments reach, including the named objects.
154    pub objects: BTreeSet<CacheDigest>,
155}
156
157/// One Cargo workspace and the target directory recorded for its build.
158#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
159pub struct WorkspaceTarget {
160    pub workspace_root: PathBuf,
161    pub target_dir: PathBuf,
162}
163
164#[derive(Debug, Serialize, Deserialize)]
165#[serde(deny_unknown_fields)]
166struct ExportManifest {
167    version: u8,
168    tasks: Vec<TaskActionManifest>,
169    actions: Vec<CacheDigest>,
170    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
171    attachments: BTreeMap<String, CacheDigest>,
172    #[serde(default, skip_serializing_if = "Vec::is_empty")]
173    objects: Vec<CacheDigest>,
174}
175
176/// The exact cache predictions completed by one top-level build command.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(deny_unknown_fields)]
179struct BuildReceipt {
180    version: u8,
181    workspace_root: PathBuf,
182    identity: String,
183    completed_nanos: u64,
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    group: Option<String>,
186    /// The restored record whose predictions this receipt carries.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    restored: Option<String>,
189    predictions: Vec<ActionPrediction>,
190}
191
192/// Record an exact completed build for checkout and grouped exports.
193pub fn record_build_receipt(
194    store: &Path,
195    run: &str,
196    identity: &str,
197    workspace_root: &Path,
198    group: Option<&str>,
199    predictions: Vec<ActionPrediction>,
200) -> Result<()> {
201    if !is_task_identity(run) || !is_task_identity(identity) {
202        eyre::bail!("invalid build receipt identity");
203    }
204    if !(TaskActionManifest {
205        version: 1,
206        task: identity.to_owned(),
207        predictions: predictions.clone(),
208    })
209    .validate()
210    {
211        eyre::bail!("invalid build receipt prediction");
212    }
213    if let Some(group) = group {
214        validate_export_group(group)?;
215    }
216    // A build that used exactly what the last one did leaves that receipt
217    // standing. Only its timestamp would change, and the export it feeds
218    // wants the prediction set, not the hour. A group receipt is per run and
219    // always written.
220    if group.is_none()
221        && read_build_receipt(&latest_receipt_path(store, workspace_root)).is_some_and(|latest| {
222            latest.identity == identity
223                && latest.workspace_root == workspace_root
224                && latest.group.is_none()
225                && latest.predictions == predictions
226        })
227    {
228        return Ok(());
229    }
230    let mut lock = lock_receipts(store, workspace_root)?;
231    lock.lock()?;
232    let (predictions, restored) = if group.is_some() {
233        carry_restored_predictions(store, workspace_root, identity, predictions)
234    } else {
235        (predictions, None)
236    };
237    let completed_nanos = SystemTime::now()
238        .duration_since(UNIX_EPOCH)
239        .map(|elapsed| elapsed.as_nanos().try_into().unwrap_or(u64::MAX))
240        .unwrap_or_default();
241    let receipt = BuildReceipt {
242        version: BUILD_RECEIPT_VERSION,
243        workspace_root: workspace_root.to_path_buf(),
244        identity: identity.to_owned(),
245        completed_nanos,
246        group: group.map(str::to_owned),
247        restored,
248        predictions,
249    };
250    let bytes = serde_json::to_vec(&receipt)?;
251    write_atomic(&latest_receipt_path(store, workspace_root), &bytes)?;
252    if let Some(group) = group {
253        write_atomic(&group_receipt_path(store, group, run), &bytes)?;
254    }
255    Ok(())
256}
257
258fn lock_receipts(store: &Path, workspace_root: &Path) -> Result<fslock::LockFile> {
259    let lock_path = store
260        .join(BUILD_RECEIPTS_DIR)
261        .join("locks")
262        .join(format!("{}.lock", workspace_key(workspace_root)));
263    std::fs::create_dir_all(lock_path.parent().expect("receipt lock has a parent"))?;
264    Ok(fslock::LockFile::open(&lock_path)?)
265}
266
267/// The predictions a restored Cargo target stands for.
268#[derive(Debug, Serialize, Deserialize)]
269#[serde(deny_unknown_fields)]
270struct RestoredPredictions {
271    version: u8,
272    /// Names this restore, so an export retires only the record its receipts
273    /// carried and not a newer restore that took its place.
274    id: String,
275    workspace_root: PathBuf,
276    tasks: Vec<TaskActionManifest>,
277}
278
279/// Remember which actions the Cargo workspace state restored into a checkout
280/// stands for.
281///
282/// Cargo skips every unit its restored fingerprints call fresh, so no compiler
283/// runs for them and a grouped build would otherwise report none of the
284/// actions that produced them. Each grouped receipt for this checkout carries
285/// the ones the build did not recompile, and the record follows what those
286/// builds recompiled; the grouped export retires it.
287pub fn record_restored_predictions(
288    store: &Path,
289    workspace_root: &Path,
290    tasks: &[TaskActionManifest],
291) -> Result<()> {
292    let nanos = SystemTime::now()
293        .duration_since(UNIX_EPOCH)
294        .map(|elapsed| elapsed.as_nanos())
295        .unwrap_or_default();
296    let id = CacheDigest::blake3(
297        format!(
298            "{}\0{nanos}\0{}",
299            std::process::id(),
300            workspace_root.display()
301        )
302        .as_bytes(),
303    )
304    .hash;
305    let bytes = serde_json::to_vec(&RestoredPredictions {
306        version: 1,
307        id,
308        workspace_root: workspace_root.to_path_buf(),
309        tasks: tasks.to_vec(),
310    })?;
311    let mut lock = lock_receipts(store, workspace_root)?;
312    lock.lock()?;
313    write_atomic(&restored_predictions_path(store, workspace_root), &bytes)
314}
315
316/// Forget what a restored target stood for, because that target is gone.
317pub fn clear_restored_predictions(store: &Path, workspace_root: &Path) -> Result<()> {
318    let mut lock = lock_receipts(store, workspace_root)?;
319    lock.lock()?;
320    match std::fs::remove_file(restored_predictions_path(store, workspace_root)) {
321        Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(error.into()),
322        _ => Ok(()),
323    }
324}
325
326/// Whether any checkout in this store has a restored record waiting.
327pub fn restored_predictions_pending(store: &Path) -> bool {
328    walk_files(&store.join(BUILD_RECEIPTS_DIR).join("restored"))
329        .is_ok_and(|files| !files.is_empty())
330}
331
332fn restored_predictions_path(store: &Path, workspace_root: &Path) -> PathBuf {
333    store
334        .join(BUILD_RECEIPTS_DIR)
335        .join("restored")
336        .join(format!("{}.json", workspace_key(workspace_root)))
337}
338
339fn read_restored_predictions(store: &Path, workspace_root: &Path) -> Option<RestoredPredictions> {
340    std::fs::read(restored_predictions_path(store, workspace_root))
341        .ok()
342        .and_then(|bytes| serde_json::from_slice::<RestoredPredictions>(&bytes).ok())
343        .filter(|restored| restored.version == 1 && restored.workspace_root == workspace_root)
344}
345
346/// Add the restored predictions this build did not supersede to its own, and
347/// the id of the record they came from.
348///
349/// A prediction for an invocation the build recompiled is replaced by the
350/// build's, so an edited crate does not keep its old action, and the record
351/// takes the replacement so a later fresh build does not bring the old one
352/// back. One whose result is no longer in the store cannot be exported and is
353/// left out. When the two together exceed a manifest's limit, the oldest
354/// restored predictions go first. The caller holds the workspace's receipt lock.
355fn carry_restored_predictions(
356    store: &Path,
357    workspace_root: &Path,
358    identity: &str,
359    predictions: Vec<ActionPrediction>,
360) -> (Vec<ActionPrediction>, Option<String>) {
361    let Some(mut restored) = read_restored_predictions(store, workspace_root) else {
362        return (predictions, None);
363    };
364    let Some(task) = restored.tasks.iter_mut().find(|task| task.task == identity) else {
365        return (predictions, None);
366    };
367    let action_cache = mbx_cache_core::LocalActionCache::new(store);
368    let carried = task
369        .predictions
370        .iter()
371        .filter(|prediction| {
372            action_cache
373                .path_for(&prediction.action)
374                .is_ok_and(|path| path.is_file())
375        })
376        .cloned()
377        .collect::<Vec<_>>();
378    let protected = predictions
379        .iter()
380        .map(|prediction| prediction.invocation.clone())
381        .collect::<BTreeSet<_>>();
382    let Ok(combined) = merge_task_action_predictions(carried, predictions.clone(), &protected)
383    else {
384        return (predictions, None);
385    };
386    let valid = TaskActionManifest {
387        version: 1,
388        task: identity.to_owned(),
389        predictions: combined.clone(),
390    }
391    .validate();
392    if !valid {
393        return (predictions, None);
394    }
395    task.predictions = combined.clone();
396    let id = restored.id.clone();
397    if let Ok(bytes) = serde_json::to_vec(&restored) {
398        let _ = write_atomic(&restored_predictions_path(store, workspace_root), &bytes);
399    }
400    (combined, Some(id))
401}
402
403fn workspace_key(workspace_root: &Path) -> String {
404    CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash
405}
406
407fn latest_receipt_path(store: &Path, workspace_root: &Path) -> PathBuf {
408    store
409        .join(BUILD_RECEIPTS_DIR)
410        .join("checkouts")
411        .join(format!("{}.json", workspace_key(workspace_root)))
412}
413
414fn group_receipt_path(store: &Path, group: &str, run: &str) -> PathBuf {
415    store
416        .join(BUILD_RECEIPTS_DIR)
417        .join("groups")
418        .join(group_key(group))
419        .join(format!("{run}.json"))
420}
421
422fn group_key(group: &str) -> String {
423    CacheDigest::blake3(group.as_bytes()).hash
424}
425
426fn read_build_receipt(path: &Path) -> Option<BuildReceipt> {
427    let bytes = std::fs::read(path).ok()?;
428    let receipt = serde_json::from_slice::<BuildReceipt>(&bytes).ok()?;
429    let valid = TaskActionManifest {
430        version: 1,
431        task: receipt.identity.clone(),
432        predictions: receipt.predictions.clone(),
433    }
434    .validate();
435    (receipt.version == BUILD_RECEIPT_VERSION && valid).then_some(receipt)
436}
437
438fn validate_export_group(group: &str) -> Result<()> {
439    if group.is_empty() || group.len() > 256 || group.chars().any(char::is_control) {
440        eyre::bail!("invalid build export group");
441    }
442    Ok(())
443}
444
445/// How an export is laid out on disk.
446///
447/// Both forms carry the same manifest and the same `cas/v1` and
448/// `action-results/v1` layout; they differ only in whether that tree is
449/// wrapped in a tar. A transport that archives and compresses a directory
450/// itself, such as `actions/cache`, would otherwise write every byte twice:
451/// once into the tar and once again when the importer unpacks it.
452#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
453pub enum ExportForm {
454    /// One tar file: portable, and what a standalone bundle should be.
455    #[default]
456    Tar,
457    /// A directory tree, for transports that archive directories themselves.
458    Directory,
459}
460
461impl std::str::FromStr for ExportForm {
462    type Err = eyre::Report;
463
464    fn from_str(text: &str) -> Result<Self> {
465        match text {
466            "tar" => Ok(Self::Tar),
467            "directory" => Ok(Self::Directory),
468            other => eyre::bail!("unknown export format {other:?}; expected tar or directory"),
469        }
470    }
471}
472
473/// Export the complete local cache closure of this checkout's most recent build.
474pub fn export_checkout(
475    store: &Path,
476    workspace_root: &Path,
477    archive: &Path,
478) -> Result<TransferOutcome> {
479    let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
480        .filter(|receipt| receipt.workspace_root == workspace_root)
481        .ok_or_else(|| {
482            eyre::eyre!(
483                "no completed mbx build is recorded for {}",
484                workspace_root.display()
485            )
486        })?;
487    export_receipts(
488        store,
489        vec![receipt],
490        archive,
491        ExportAdditions::default(),
492        ExportForm::Tar,
493    )
494}
495
496/// Return the target directory recorded for this checkout's latest build.
497pub fn checkout_workspace_target(
498    store: &Path,
499    workspace_root: &Path,
500) -> Result<Option<WorkspaceTarget>> {
501    let Some(receipt) = read_build_receipt(&latest_receipt_path(store, workspace_root))
502        .filter(|receipt| receipt.workspace_root == workspace_root)
503    else {
504        return Ok(None);
505    };
506    Ok(workspace_target_for_receipt(store, &receipt))
507}
508
509/// Return every Cargo target represented by the pending receipts in a group.
510pub fn group_workspace_targets(store: &Path, group: &str) -> Result<Vec<WorkspaceTarget>> {
511    validate_export_group(group)?;
512    let root = store
513        .join(BUILD_RECEIPTS_DIR)
514        .join("groups")
515        .join(group_key(group));
516    let targets = walk_files(&root)?
517        .into_iter()
518        .filter_map(|entry| read_build_receipt(&entry.path))
519        .filter(|receipt| receipt.group.as_deref() == Some(group))
520        .filter_map(|receipt| workspace_target_for_receipt(store, &receipt))
521        .collect::<BTreeSet<_>>();
522    Ok(targets.into_iter().collect())
523}
524
525fn workspace_target_for_receipt(store: &Path, receipt: &BuildReceipt) -> Option<WorkspaceTarget> {
526    let record = read_checkout_record(&checkout_record_path(
527        store,
528        &receipt.identity,
529        &receipt.workspace_root,
530    ))?;
531    (record.workspace_root == receipt.workspace_root && record.target_dir != record.workspace_root)
532        .then_some(WorkspaceTarget {
533            workspace_root: record.workspace_root,
534            target_dir: record.target_dir,
535        })
536}
537
538/// Export one checkout's closure together with higher-level CAS attachments.
539pub fn export_checkout_with(
540    store: &Path,
541    workspace_root: &Path,
542    archive: &Path,
543    additions: ExportAdditions,
544) -> Result<TransferOutcome> {
545    let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
546        .filter(|receipt| receipt.workspace_root == workspace_root)
547        .ok_or_else(|| {
548            eyre::eyre!(
549                "no completed mbx build is recorded for {}",
550                workspace_root.display()
551            )
552        })?;
553    export_receipts(store, vec![receipt], archive, additions, ExportForm::Tar)
554}
555
556/// Export one checkout's closure in the requested form.
557pub fn export_checkout_as(
558    store: &Path,
559    workspace_root: &Path,
560    destination: &Path,
561    additions: ExportAdditions,
562    form: ExportForm,
563) -> Result<TransferOutcome> {
564    let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
565        .filter(|receipt| receipt.workspace_root == workspace_root)
566        .ok_or_else(|| {
567            eyre::eyre!(
568                "no completed mbx build is recorded for {}",
569                workspace_root.display()
570            )
571        })?;
572    export_receipts(store, vec![receipt], destination, additions, form)
573}
574
575/// Export the union of every completed build recorded under one CI group.
576pub fn export_group(store: &Path, group: &str, archive: &Path) -> Result<TransferOutcome> {
577    export_group_with(store, group, archive, ExportAdditions::default())
578}
579
580/// Export a grouped closure together with higher-level CAS attachments.
581pub fn export_group_with(
582    store: &Path,
583    group: &str,
584    archive: &Path,
585    additions: ExportAdditions,
586) -> Result<TransferOutcome> {
587    export_group_as(store, group, archive, additions, ExportForm::Tar)
588}
589
590/// Export a grouped closure in the requested form.
591pub fn export_group_as(
592    store: &Path,
593    group: &str,
594    destination: &Path,
595    additions: ExportAdditions,
596    form: ExportForm,
597) -> Result<TransferOutcome> {
598    validate_export_group(group)?;
599    let root = store
600        .join(BUILD_RECEIPTS_DIR)
601        .join("groups")
602        .join(group_key(group));
603    let receipts = walk_files(&root)?
604        .into_iter()
605        .filter_map(|entry| read_build_receipt(&entry.path).map(|receipt| (entry.path, receipt)))
606        .filter(|(_, receipt)| receipt.group.as_deref() == Some(group))
607        .collect::<Vec<_>>();
608    if receipts.is_empty() {
609        eyre::bail!("no completed mbx builds are recorded for export group {group:?}");
610    }
611    let outcome = export_receipts(
612        store,
613        receipts
614            .iter()
615            .map(|(_, receipt)| receipt.clone())
616            .collect(),
617        destination,
618        additions,
619        form,
620    )?;
621    // A receipt is a pending-export root. Retire only the files this export
622    // consumed, and only after its complete archive has been published. A
623    // concurrent build can add another uniquely named receipt to the group
624    // without this cleanup deleting work the archive did not include.
625    let mut carried = BTreeMap::<PathBuf, BTreeSet<String>>::new();
626    for (path, receipt) in receipts {
627        std::fs::remove_file(path)?;
628        let ids = carried.entry(receipt.workspace_root).or_default();
629        ids.extend(receipt.restored);
630    }
631    let _ = std::fs::remove_dir(root);
632    // The export carried what the restored state stood for, so the record has
633    // done its job. Left behind, it would feed a later group in this store.
634    // Only the record these receipts carried is retired: a restore that came
635    // after them has not been exported yet.
636    for (workspace_root, ids) in carried {
637        let Ok(mut lock) = lock_receipts(store, &workspace_root) else {
638            continue;
639        };
640        if lock.lock().is_err() {
641            continue;
642        }
643        if read_restored_predictions(store, &workspace_root)
644            .is_some_and(|restored| ids.contains(&restored.id))
645        {
646            let _ = std::fs::remove_file(restored_predictions_path(store, &workspace_root));
647        }
648    }
649    Ok(outcome)
650}
651
652fn export_receipts(
653    store: &Path,
654    mut receipts: Vec<BuildReceipt>,
655    archive: &Path,
656    additions: ExportAdditions,
657    form: ExportForm,
658) -> Result<TransferOutcome> {
659    receipts.sort_by(|left, right| {
660        left.completed_nanos
661            .cmp(&right.completed_nanos)
662            .then_with(|| {
663                // Concurrent commands may complete on the same clock tick. Use
664                // their persisted predictions to break ties, not directory order.
665                fn key(prediction: &ActionPrediction) -> (&CacheDigest, &CacheDigest, &str, &str) {
666                    (
667                        &prediction.invocation,
668                        &prediction.action,
669                        &prediction.adapter,
670                        &prediction.payload,
671                    )
672                }
673                left.predictions
674                    .iter()
675                    .map(key)
676                    .cmp(right.predictions.iter().map(key))
677            })
678    });
679    let mut actions = BTreeSet::new();
680    let mut tasks = BTreeMap::new();
681    for receipt in receipts {
682        actions.extend(
683            receipt
684                .predictions
685                .iter()
686                .map(|prediction| prediction.action.clone()),
687        );
688        // Keep predictions from every command in receipt order. Deduplication
689        // below makes the most recently completed command win an overlap.
690        tasks
691            .entry(receipt.identity)
692            .or_insert_with(Vec::new)
693            .extend(receipt.predictions);
694    }
695    let tasks = tasks
696        .into_iter()
697        .map(|(task, predictions)| {
698            let mut invocations = BTreeSet::new();
699            let mut predictions = predictions
700                .into_iter()
701                .rev()
702                .filter(|prediction| invocations.insert(prediction.invocation.clone()))
703                .collect::<Vec<_>>();
704            predictions.reverse();
705            TaskActionManifest {
706                version: 1,
707                task,
708                predictions,
709            }
710        })
711        .collect::<Vec<_>>();
712    if tasks.iter().any(|task| !task.validate()) {
713        eyre::bail!("combined export predictions exceed task manifest limits");
714    }
715    validate_export_additions(&additions)?;
716    let mut closure = strict_closure(store, &actions)?;
717    let cas = LocalCas::new(store);
718    for digest in &additions.objects {
719        require_object(&cas, &mut closure, digest)?;
720    }
721    // Hash the leaves before they are packed. An export is what another
722    // machine will trust, so publishing a blob this store has since corrupted
723    // would move the failure to whoever restores it.
724    verify_pending(&mut closure.pending).wrap_err("cache closure is incomplete or corrupt")?;
725    let parent = archive
726        .parent()
727        .filter(|parent| !parent.as_os_str().is_empty())
728        .unwrap_or_else(|| Path::new("."));
729    std::fs::create_dir_all(parent)?;
730    let manifest = serde_json::to_vec(&ExportManifest {
731        version: if additions.attachments.is_empty() && additions.objects.is_empty() {
732            LEGACY_EXPORT_VERSION
733        } else {
734            EXPORT_VERSION
735        },
736        tasks,
737        actions: actions.iter().cloned().collect(),
738        attachments: additions.attachments.clone(),
739        objects: additions.objects.iter().cloned().collect(),
740    })?;
741    let members = closure
742        .objects
743        .iter()
744        .chain(closure.results.iter())
745        .collect::<Vec<_>>();
746    let bytes = match form {
747        ExportForm::Tar => write_tar_export(store, archive, parent, &manifest, &members)?,
748        ExportForm::Directory => {
749            write_directory_export(store, archive, parent, &manifest, &members)?
750        }
751    };
752    Ok(TransferOutcome {
753        actions: actions.len() as u64,
754        objects: closure.objects.len() as u64,
755        bytes,
756    })
757}
758
759/// Write the export as one tar file, published atomically by rename.
760fn write_tar_export(
761    store: &Path,
762    archive: &Path,
763    parent: &Path,
764    manifest: &[u8],
765    members: &[&PathBuf],
766) -> Result<u64> {
767    let temporary = tempfile::Builder::new()
768        .prefix(".mbx-export-")
769        .tempfile_in(parent)?;
770    let mut builder = tar::Builder::new(temporary.reopen()?);
771    append_bytes(&mut builder, Path::new(EXPORT_MANIFEST), manifest)?;
772    for path in members {
773        append_file(&mut builder, store, path)?;
774    }
775    builder.finish()?;
776    drop(builder);
777    temporary
778        .persist(archive)
779        .map_err(|error| error.error)
780        .wrap_err_with(|| format!("failed to publish {}", archive.display()))?;
781    Ok(std::fs::metadata(archive)?.len())
782}
783
784/// Write the export as a directory, published atomically by rename.
785///
786/// Built beside the destination and moved into place, so an interrupted export
787/// leaves a `.mbx-export-` directory rather than a bundle that looks complete
788/// and is not.
789fn write_directory_export(
790    store: &Path,
791    destination: &Path,
792    parent: &Path,
793    manifest: &[u8],
794    members: &[&PathBuf],
795) -> Result<u64> {
796    let staging = tempfile::Builder::new()
797        .prefix(".mbx-export-")
798        .tempdir_in(parent)?;
799    std::fs::write(staging.path().join(EXPORT_MANIFEST), manifest)?;
800    let mut bytes = manifest.len() as u64;
801    for path in members {
802        let relative = path.strip_prefix(store)?;
803        let target = staging.path().join(relative);
804        std::fs::create_dir_all(target.parent().expect("export member has a parent"))?;
805        bytes += std::fs::copy(path, &target)?;
806    }
807    let staged = staging.keep();
808    // `rename` will not replace a populated directory, so an existing bundle
809    // has to move out of the way first. Move it aside rather than deleting it:
810    // if publication then fails, deleting first would have left no bundle at
811    // all, which is worse than the stale one the caller started with.
812    let retired = match std::fs::symlink_metadata(destination) {
813        Ok(_) => {
814            let holder = tempfile::Builder::new()
815                .prefix(".mbx-export-retired-")
816                .tempdir_in(parent)?;
817            let moved = holder.path().join("bundle");
818            std::fs::rename(destination, &moved)
819                .wrap_err_with(|| format!("failed to replace {}", destination.display()))?;
820            Some((holder, moved))
821        }
822        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
823        Err(error) => {
824            return Err(error)
825                .wrap_err_with(|| format!("failed to replace {}", destination.display()));
826        }
827    };
828    if let Err(error) = std::fs::rename(&staged, destination) {
829        let _ = std::fs::remove_dir_all(&staged);
830        if let Some((holder, moved)) = retired
831            && std::fs::rename(&moved, destination).is_err()
832        {
833            // Putting the old bundle back has failed too, and letting the
834            // holder drop here would delete the only remaining copy of it.
835            // Keep it instead, and say where it went: a stale bundle the
836            // caller has to move back by hand still beats no bundle at all.
837            let kept = holder.keep();
838            log::warn!(
839                "could not restore {} after a failed export; its previous contents are at {}",
840                destination.display(),
841                kept.join("bundle").display()
842            );
843        }
844        return Err(error).wrap_err_with(|| format!("failed to publish {}", destination.display()));
845    }
846    // The replaced bundle is only discarded once the new one is in place.
847    drop(retired);
848    Ok(bytes)
849}
850
851/// Validate a cache export in isolation, then publish its objects and actions.
852pub fn import_archive(store: &Path, archive: &Path) -> Result<TransferOutcome> {
853    Ok(import_archive_with_attachments(store, archive)?.transfer)
854}
855
856/// Import a cache archive and return its named higher-level CAS roots.
857pub fn import_archive_with_attachments(store: &Path, archive: &Path) -> Result<ImportOutcome> {
858    // A directory bundle is read where it lies. Unpacking one into staging
859    // would write every byte a second time, which is the whole cost the
860    // directory form exists to avoid.
861    let staged;
862    let _staging_lock;
863    let root = if archive.is_dir() {
864        _staging_lock = None;
865        validate_directory_bundle(archive)?;
866        archive
867    } else {
868        // Stage inside the store, not the system temp directory. Publication
869        // moves the verified files with `fs::rename`, which only works within
870        // one filesystem; a `$TMPDIR` on another device silently downgrades
871        // every object to a copy and a second hash, and unpacking a
872        // multi-gigabyte export into a container's `/tmp` can run out of room.
873        let staging_root = store.join(IMPORT_STAGING_DIR);
874        std::fs::create_dir_all(&staging_root)?;
875        staged = tempfile::Builder::new()
876            .prefix("import-")
877            .tempdir_in(&staging_root)?;
878        // Held for the rest of the import so a concurrent sweep can tell this
879        // tree from one a killed process abandoned. The gap between creating
880        // the directory and taking the lock needs no closing: a sweep only
881        // considers trees a day old, and this one is new.
882        _staging_lock = Some(lock_import_staging(staged.path())?);
883        unpack_archive(archive, staged.path())?;
884        staged.path()
885    };
886    // Measure a directory bundle now: publication moves its objects into the
887    // store, so by the end there is nothing left to measure.
888    let bundle_bytes = archive.is_dir().then(|| tree_bytes(archive));
889    let manifest: ExportManifest =
890        serde_json::from_slice(&std::fs::read(root.join(EXPORT_MANIFEST))?)?;
891    let actions = manifest.actions.iter().cloned().collect::<BTreeSet<_>>();
892    let task_identities = manifest
893        .tasks
894        .iter()
895        .map(|task| task.task.as_str())
896        .collect::<BTreeSet<_>>();
897    if !matches!(manifest.version, LEGACY_EXPORT_VERSION | EXPORT_VERSION)
898        || (manifest.version == LEGACY_EXPORT_VERSION
899            && (!manifest.attachments.is_empty() || !manifest.objects.is_empty()))
900        || manifest.tasks.is_empty()
901        || actions.len() != manifest.actions.len()
902        || task_identities.len() != manifest.tasks.len()
903        || manifest.tasks.iter().any(|task| !task.validate())
904        || manifest
905            .actions
906            .iter()
907            .any(|action| action.algorithm != "blake3" || action.validate().is_err())
908        || manifest.tasks.iter().any(|task| {
909            task.predictions
910                .iter()
911                .any(|prediction| !actions.contains(&prediction.action))
912        })
913        || manifest
914            .attachments
915            .keys()
916            .any(|name| !valid_attachment_name(name))
917        || manifest.attachments.values().any(|digest| {
918            digest.algorithm != "blake3"
919                || digest.validate().is_err()
920                || !manifest.objects.contains(digest)
921        })
922        || manifest.objects.iter().collect::<BTreeSet<_>>().len() != manifest.objects.len()
923        || manifest
924            .objects
925            .iter()
926            .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
927    {
928        eyre::bail!("unsupported or invalid cache export manifest");
929    }
930    let mut closure =
931        strict_closure(root, &actions).wrap_err("cache export is incomplete or corrupt")?;
932    let staged_cas = LocalCas::new(root);
933    for digest in &manifest.objects {
934        require_object(&staged_cas, &mut closure, digest)
935            .wrap_err("cache export attachment is incomplete or corrupt")?;
936    }
937    verify_pending(&mut closure.pending).wrap_err("cache export is incomplete or corrupt")?;
938
939    // Read every action result before anything is published. Publication moves
940    // a directory bundle's objects into the store, and a bundle cannot be
941    // re-imported once that has happened, so every failure that can be moved
942    // ahead of it should be: a malformed result has to fail while the bundle
943    // is still whole. Publication can still fail (for example on store I/O or
944    // a concurrent invalid action write); it does not roll back moved objects.
945    // Intact conflicting results follow the first-writer rule and are kept.
946    let results = closure
947        .results
948        .iter()
949        .map(|path| {
950            let result: RemoteActionResult = serde_json::from_slice(&std::fs::read(path)?)
951                .wrap_err_with(|| format!("action result is invalid: {}", path.display()))?;
952            Ok(result)
953        })
954        .collect::<Result<Vec<_>>>()
955        .wrap_err("cache export is incomplete or corrupt")?;
956
957    let cas = LocalCas::new(store);
958    for path in &closure.objects {
959        let relative = path.strip_prefix(root)?;
960        let source = root.join(relative);
961        let digest = addressed_digest(root, &source, false)
962            .ok_or_else(|| eyre::eyre!("invalid cache object path {}", relative.display()))?;
963        // `strict_closure` verified every path in `objects` against its
964        // content-addressed name. Preserve that proof and move the owned
965        // staging file instead of hashing or copying the bytes again.
966        cas.adopt_verified_file(&digest, &source)?;
967    }
968    let action_cache = mbx_cache_core::LocalActionCache::new(store);
969    let mut kept_existing = 0;
970    for result in &results {
971        if action_cache.store_unless_restorable(result, |existing| {
972            extend_result_closure(
973                &cas,
974                &mut Closure::default(),
975                &mut BTreeSet::new(),
976                existing,
977                false,
978            )
979            .is_ok()
980        })? == mbx_cache_core::ActionStoreOutcome::KeptExisting
981        {
982            kept_existing += 1;
983        }
984    }
985    if kept_existing > 0 {
986        log::debug!(
987            "cache import kept {kept_existing} intact existing results that differed from the bundle"
988        );
989    }
990    for task in manifest.tasks.iter().cloned() {
991        merge_imported_manifest(store, task)?;
992    }
993    let bytes = match bundle_bytes {
994        Some(bytes) => {
995            // Publication moved the objects out of the bundle, so what is left
996            // is a shell of empty directories. Removing it keeps a restored
997            // bundle out of the job's disk budget for the rest of the run.
998            std::fs::remove_dir_all(archive)
999                .wrap_err_with(|| format!("failed to remove {}", archive.display()))?;
1000            bytes
1001        }
1002        None => std::fs::metadata(archive)?.len(),
1003    };
1004    Ok(ImportOutcome {
1005        transfer: TransferOutcome {
1006            actions: actions.len() as u64,
1007            objects: closure.objects.len() as u64,
1008            bytes,
1009        },
1010        attachments: manifest.attachments,
1011        tasks: manifest.tasks,
1012    })
1013}
1014
1015fn validate_export_additions(additions: &ExportAdditions) -> Result<()> {
1016    if additions
1017        .attachments
1018        .keys()
1019        .any(|name| !valid_attachment_name(name))
1020        || additions
1021            .attachments
1022            .values()
1023            .any(|digest| !additions.objects.contains(digest))
1024        || additions
1025            .objects
1026            .iter()
1027            .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
1028    {
1029        eyre::bail!("invalid cache export attachment");
1030    }
1031    Ok(())
1032}
1033
1034fn valid_attachment_name(name: &str) -> bool {
1035    !name.is_empty()
1036        && name.len() <= 128
1037        && name
1038            .bytes()
1039            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
1040}
1041
1042/// Everything one closure walk reached, and the leaves still to be hashed.
1043#[derive(Default)]
1044struct Closure {
1045    objects: BTreeSet<PathBuf>,
1046    results: BTreeSet<PathBuf>,
1047    pending: Vec<PendingObject>,
1048}
1049
1050/// A leaf object recorded by the walk and awaiting content verification.
1051struct PendingObject {
1052    digest: CacheDigest,
1053    path: PathBuf,
1054}
1055
1056fn strict_closure(store: &Path, actions: &BTreeSet<CacheDigest>) -> Result<Closure> {
1057    let cas = LocalCas::new(store);
1058    let action_cache = mbx_cache_core::LocalActionCache::new(store);
1059    let mut closure = Closure::default();
1060    let mut directories = BTreeSet::new();
1061    for action in actions {
1062        let result = action_cache
1063            .find(action)?
1064            .ok_or_else(|| eyre::eyre!("action result is missing for {}", action.hash))?;
1065        closure.results.insert(action_cache.path_for(action)?);
1066        extend_result_closure(&cas, &mut closure, &mut directories, &result, true)?;
1067    }
1068    Ok(closure)
1069}
1070
1071/// Walk one result's dependencies. Export/import validation hashes descriptors;
1072/// checking an existing result for repair only needs presence and length checks.
1073fn extend_result_closure(
1074    cas: &LocalCas,
1075    closure: &mut Closure,
1076    directories: &mut BTreeSet<CacheDigest>,
1077    result: &RemoteActionResult,
1078    verify_descriptors: bool,
1079) -> Result<()> {
1080    let parsed_object = |closure: &mut Closure, digest: &CacheDigest| {
1081        if verify_descriptors {
1082            require_parsed_object(cas, closure, digest)
1083        } else {
1084            require_object(cas, closure, digest)?;
1085            cas.path_for(digest)
1086        }
1087    };
1088    require_object(cas, closure, &result.action)?;
1089    if let Some(metadata) = &result.metadata {
1090        let path = parsed_object(closure, metadata)?;
1091        let captured: CapturedOutput =
1092            serde_json::from_slice(&std::fs::read(path)?).wrap_err("action metadata is invalid")?;
1093        require_object(cas, closure, &captured.stdout)?;
1094        require_object(cas, closure, &captured.stderr)?;
1095    }
1096    if let Some(root) = &result.output_root {
1097        let mut nodes = vec![root.clone()];
1098        while let Some(digest) = nodes.pop() {
1099            if !directories.insert(digest.clone()) {
1100                continue;
1101            }
1102            let path = parsed_object(closure, &digest)?;
1103            let directory: CacheDirectory = serde_json::from_slice(&std::fs::read(path)?)
1104                .wrap_err("output directory is invalid")?;
1105            for file in directory.files {
1106                require_object(cas, closure, &file.digest)?;
1107            }
1108            for child in directory.directories {
1109                nodes.push(child.digest);
1110            }
1111        }
1112    }
1113    Ok(())
1114}
1115
1116/// Verify every recorded leaf's contents, spread across the machine.
1117///
1118/// Callers must run this before publishing anything the walk reached: it is
1119/// the step that proves the bytes match their content-addressed names, and
1120/// both the exporter and the importer depend on that proof. A closure of a
1121/// few thousand compiler outputs is several gigabytes read once, and the
1122/// hash, not the read, is what saturates a core.
1123fn verify_pending(pending: &mut [PendingObject]) -> Result<()> {
1124    if pending.is_empty() {
1125        return Ok(());
1126    }
1127    // Longest first. Handing out a half-gigabyte rlib last would leave one
1128    // worker hashing it alone after the others have finished, and the lengths
1129    // cost nothing: the walk already checked each one against its digest.
1130    pending.sort_by(|left, right| {
1131        right
1132            .digest
1133            .size
1134            .cmp(&left.digest.size)
1135            .then_with(|| left.path.cmp(&right.path))
1136    });
1137    let workers = std::thread::available_parallelism()
1138        .map(usize::from)
1139        .unwrap_or(1)
1140        .min(pending.len());
1141    let next = AtomicUsize::new(0);
1142    // Report the lowest failing path rather than whichever worker happened to
1143    // lose the race, so a corrupt closure names the same object every run.
1144    let failure: Mutex<Option<(PathBuf, eyre::Report)>> = Mutex::new(None);
1145    let pending: &[PendingObject] = pending;
1146    std::thread::scope(|scope| {
1147        for _ in 0..workers {
1148            scope.spawn(|| {
1149                loop {
1150                    let index = next.fetch_add(1, Ordering::Relaxed);
1151                    let Some(entry) = pending.get(index) else {
1152                        break;
1153                    };
1154                    let error = match entry.digest.matches_file(&entry.path) {
1155                        Ok(true) => continue,
1156                        Ok(false) => eyre::eyre!(
1157                            "local CAS blob failed digest verification: {}",
1158                            entry.path.display()
1159                        ),
1160                        Err(error) => {
1161                            error.wrap_err(format!("failed to verify {}", entry.path.display()))
1162                        }
1163                    };
1164                    let mut failure = failure.lock().unwrap_or_else(|error| error.into_inner());
1165                    if failure.as_ref().is_none_or(|(path, _)| entry.path < *path) {
1166                        *failure = Some((entry.path.clone(), error));
1167                    }
1168                }
1169            });
1170        }
1171    });
1172    match failure
1173        .into_inner()
1174        .unwrap_or_else(|error| error.into_inner())
1175    {
1176        Some((_, error)) => Err(error),
1177        None => Ok(()),
1178    }
1179}
1180
1181#[derive(Deserialize)]
1182struct CapturedOutput {
1183    stdout: CacheDigest,
1184    stderr: CacheDigest,
1185}
1186
1187fn merge_imported_manifest(destination: &Path, mut imported: TaskActionManifest) -> Result<()> {
1188    let identity = imported.task.clone();
1189    let destination_path = task_manifest_path(destination, &identity);
1190    let lock_path = task_manifest_lock_path(destination, &identity);
1191    std::fs::create_dir_all(lock_path.parent().expect("task manifest lock has a parent"))?;
1192    let mut lock = fslock::LockFile::open(&lock_path)?;
1193    lock.lock()?;
1194    if let Ok(bytes) = std::fs::read(&destination_path)
1195        && let Ok(existing) = serde_json::from_slice::<TaskActionManifest>(&bytes)
1196        && existing.task == identity
1197        && existing.validate()
1198    {
1199        let existing_predictions: BTreeMap<_, _> = existing
1200            .predictions
1201            .iter()
1202            .cloned()
1203            .map(|prediction| (prediction.invocation.clone(), prediction))
1204            .collect();
1205        // The bundle determines recency, but a checkout's current value wins
1206        // when both manifests predict the same invocation.
1207        let updates = imported
1208            .predictions
1209            .into_iter()
1210            .map(|prediction| {
1211                existing_predictions
1212                    .get(&prediction.invocation)
1213                    .cloned()
1214                    .unwrap_or(prediction)
1215            })
1216            .collect();
1217        imported.predictions =
1218            merge_task_action_predictions(existing.predictions, updates, &BTreeSet::new())?;
1219    }
1220    write_atomic(&destination_path, &serde_json::to_vec(&imported)?)
1221}
1222
1223/// Record a leaf object, deferring its content hash to `verify_pending`.
1224///
1225/// Presence and declared length are settled here, while the walk can still say
1226/// cheaply which object is wrong. The contents are left for the parallel pass
1227/// because leaves are where the bytes are: compiler outputs, captured streams,
1228/// and attachments, none of which steer the walk.
1229fn require_object(cas: &LocalCas, closure: &mut Closure, digest: &CacheDigest) -> Result<()> {
1230    let expected = cas.path_for(digest)?;
1231    if !closure.objects.insert(expected.clone()) {
1232        return Ok(());
1233    }
1234    let metadata = match std::fs::metadata(&expected) {
1235        Ok(metadata) => metadata,
1236        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1237            eyre::bail!("cache object is missing for {}", digest.hash)
1238        }
1239        Err(error) => {
1240            return Err(error).wrap_err_with(|| format!("failed to read {}", expected.display()));
1241        }
1242    };
1243    if metadata.len() != digest.size {
1244        eyre::bail!(
1245            "local CAS blob failed digest verification: {}",
1246            expected.display()
1247        );
1248    }
1249    closure.pending.push(PendingObject {
1250        digest: digest.clone(),
1251        path: expected,
1252    });
1253    Ok(())
1254}
1255
1256/// Verify an object the walk is about to deserialize, and return its path.
1257///
1258/// Action metadata and directory nodes decide what the walk visits next, so a
1259/// forged one steers the walk itself and has to be trusted before `serde_json`
1260/// sees it. They are canonical JSON and small, so hashing them in line costs
1261/// little next to the leaves they point at.
1262fn require_parsed_object(
1263    cas: &LocalCas,
1264    closure: &mut Closure,
1265    digest: &CacheDigest,
1266) -> Result<PathBuf> {
1267    let path = cas
1268        .find(digest)?
1269        .ok_or_else(|| eyre::eyre!("cache object is missing for {}", digest.hash))?;
1270    closure.objects.insert(path.clone());
1271    Ok(path)
1272}
1273
1274#[derive(Default)]
1275struct ManifestPrune {
1276    removed_manifests: u64,
1277    removed_bytes: u64,
1278}
1279
1280/// Drop prediction manifests, and their locks, for identities no live checkout
1281/// claims.
1282///
1283/// A manifest has one file per task identity and nothing else removes it, so
1284/// without this the directory only grows: a manifest can reach 15 MiB and every
1285/// lockfile change mints a new identity. Rooting already ignores manifests of
1286/// unclaimed identities, so removing one changes no eviction decision; the
1287/// worst it costs is a cold prefetch the next time that identity is built.
1288///
1289/// A file younger than [`TASK_MANIFEST_GRACE`] is kept, and so is any manifest
1290/// or lock another process holds.
1291///
1292/// This is housekeeping beside the size budget, so a failure is logged and the
1293/// sweep carries on to the eviction that follows it.
1294fn prune_task_manifests(store: &Path, live: &BTreeSet<String>, dry_run: bool) -> ManifestPrune {
1295    let mut prune = ManifestPrune::default();
1296    let root = store.join(TASK_MANIFESTS_DIR);
1297    let mut removed = BTreeSet::new();
1298    let manifests = read_dir_or_empty(&root).unwrap_or_else(|error| {
1299        log::warn!("could not list task manifests: {error}");
1300        Vec::new()
1301    });
1302    for entry in manifests {
1303        let name = entry.file_name().to_string_lossy().into_owned();
1304        let Some(identity) = name.strip_suffix(".json") else {
1305            continue;
1306        };
1307        if !is_task_identity(identity) || live.contains(identity) {
1308            continue;
1309        }
1310        let Ok(metadata) = entry.metadata() else {
1311            continue;
1312        };
1313        if !metadata.is_file() || !task_manifest_is_idle(&metadata) {
1314            continue;
1315        }
1316        let lock_path = task_manifest_lock_path(store, identity);
1317        if dry_run {
1318            // A real run skips a manifest whose lock is held, so the preview
1319            // does too. A lock that does not exist yet is free, and probing
1320            // must not create it.
1321            if lock_path.exists() && claim_task_manifest_lock(&lock_path).is_none() {
1322                continue;
1323            }
1324            prune.removed_manifests += 1;
1325            prune.removed_bytes += metadata.len();
1326            continue;
1327        }
1328        // Held across the removal so a build merging into this manifest finishes
1329        // first, and one that starts afterwards writes a fresh file.
1330        let Some(guard) = claim_task_manifest_lock(&lock_path) else {
1331            continue;
1332        };
1333        // A merge that finished between the check above and the lock replaced
1334        // the file with a fresh one, so look again before deleting.
1335        let Ok(metadata) = std::fs::metadata(entry.path()) else {
1336            continue;
1337        };
1338        if !metadata.is_file() || !task_manifest_is_idle(&metadata) {
1339            continue;
1340        }
1341        match remove(&entry.path()) {
1342            Ok(Removal::Removed) => {
1343                removed.insert(identity.to_string());
1344                prune.removed_manifests += 1;
1345                prune.removed_bytes += metadata.len();
1346            }
1347            Ok(_) => {}
1348            Err(error) => log::warn!("could not remove task manifest {name}: {error}"),
1349        }
1350        drop(guard);
1351        if removed.contains(identity) {
1352            let _ = std::fs::remove_file(&lock_path);
1353        }
1354    }
1355
1356    // A lock outlives its manifest, so the directory holds one per identity
1357    // that ever built, including ones whose manifest this or an earlier sweep
1358    // removed.
1359    if !dry_run {
1360        let locks = read_dir_or_empty(&root.join("locks")).unwrap_or_else(|error| {
1361            log::warn!("could not list task manifest locks: {error}");
1362            Vec::new()
1363        });
1364        for entry in locks {
1365            let name = entry.file_name().to_string_lossy().into_owned();
1366            let Some(identity) = name.strip_suffix(".lock") else {
1367                continue;
1368            };
1369            if !is_task_identity(identity)
1370                || live.contains(identity)
1371                || task_manifest_path(store, identity).exists()
1372            {
1373                continue;
1374            }
1375            let idle = entry
1376                .metadata()
1377                .is_ok_and(|metadata| metadata.is_file() && task_manifest_is_idle(&metadata));
1378            if idle && let Some(guard) = claim_task_manifest_lock(&entry.path()) {
1379                drop(guard);
1380                let _ = std::fs::remove_file(entry.path());
1381            }
1382        }
1383    }
1384    prune
1385}
1386
1387fn task_manifest_is_idle(metadata: &std::fs::Metadata) -> bool {
1388    metadata
1389        .modified()
1390        .ok()
1391        .and_then(|at| SystemTime::now().duration_since(at).ok())
1392        .is_some_and(|age| age > TASK_MANIFEST_GRACE)
1393}
1394
1395/// The lock on a task manifest, if nothing else holds it.
1396fn claim_task_manifest_lock(path: &Path) -> Option<fslock::LockFile> {
1397    let mut lock = fslock::LockFile::open(path).ok()?;
1398    lock.try_lock().ok()?.then_some(lock)
1399}
1400
1401fn task_manifest_path(store: &Path, identity: &str) -> PathBuf {
1402    store
1403        .join(TASK_MANIFESTS_DIR)
1404        .join(format!("{identity}.json"))
1405}
1406
1407fn task_manifest_lock_path(store: &Path, identity: &str) -> PathBuf {
1408    store
1409        .join(TASK_MANIFESTS_DIR)
1410        .join("locks")
1411        .join(format!("{identity}.lock"))
1412}
1413
1414fn append_file(builder: &mut tar::Builder<std::fs::File>, root: &Path, path: &Path) -> Result<()> {
1415    let name = path.strip_prefix(root)?;
1416    builder.append_path_with_name(path, name)?;
1417    Ok(())
1418}
1419
1420fn append_bytes(
1421    builder: &mut tar::Builder<std::fs::File>,
1422    name: &Path,
1423    bytes: &[u8],
1424) -> Result<()> {
1425    let mut header = tar::Header::new_gnu();
1426    header.set_size(bytes.len() as u64);
1427    header.set_mode(0o644);
1428    header.set_cksum();
1429    builder.append_data(&mut header, name, bytes)?;
1430    Ok(())
1431}
1432
1433/// Unpack a tar export into an empty staging directory.
1434fn unpack_archive(archive: &Path, staging: &Path) -> Result<()> {
1435    let file = std::fs::File::open(archive)
1436        .wrap_err_with(|| format!("failed to open {}", archive.display()))?;
1437    let mut bundle = tar::Archive::new(file);
1438    let mut seen = BTreeSet::new();
1439    for entry in bundle.entries()? {
1440        let mut entry = entry?;
1441        let entry_type = entry.header().entry_type();
1442        if !entry_type.is_file() && !entry_type.is_gnu_sparse() {
1443            eyre::bail!("cache export contains a non-file entry");
1444        }
1445        let path = entry.path()?.into_owned();
1446        validate_archive_path(&path)?;
1447        if !seen.insert(path.clone()) {
1448            eyre::bail!("cache export contains duplicate entry {}", path.display());
1449        }
1450        let destination = staging.join(&path);
1451        std::fs::create_dir_all(destination.parent().expect("entry has a parent"))?;
1452        // `unpack` understands GNU sparse maps. A plain stream copy expands
1453        // holes into physical zeroes, which is both slower and much larger for
1454        // Rust artifacts containing sparse sections.
1455        entry.unpack(&destination)?;
1456    }
1457    Ok(())
1458}
1459
1460/// Check a directory bundle against the policy a tar bundle is held to.
1461///
1462/// A tar carries its own entry types, so the importer can refuse a symlink or
1463/// a device node by reading the header. A directory has to be walked for the
1464/// same answer, and `DirEntry::metadata` does not follow symlinks, so anything
1465/// that is not a plain file or a directory is rejected here rather than
1466/// followed out of the bundle.
1467fn validate_directory_bundle(root: &Path) -> Result<()> {
1468    let mut pending = vec![root.to_path_buf()];
1469    while let Some(directory) = pending.pop() {
1470        for entry in std::fs::read_dir(&directory)
1471            .wrap_err_with(|| format!("failed to read {}", directory.display()))?
1472        {
1473            let entry = entry?;
1474            let metadata = entry.metadata()?;
1475            let path = entry.path();
1476            if metadata.is_dir() {
1477                pending.push(path);
1478                continue;
1479            }
1480            if !metadata.is_file() {
1481                eyre::bail!("cache export contains a non-file entry");
1482            }
1483            validate_archive_path(path.strip_prefix(root)?)?;
1484            reject_linked_file(&metadata, &path)?;
1485        }
1486    }
1487    Ok(())
1488}
1489
1490/// Refuse a bundle file that shares its inode with a name outside the bundle.
1491///
1492/// Import adopts objects by moving them into the CAS. A second hard link would
1493/// survive that move and keep write access to a blob the store then treats as
1494/// verified and immutable, which is a way to change a verified object after it
1495/// has been checked.
1496#[cfg(unix)]
1497fn reject_linked_file(metadata: &std::fs::Metadata, path: &Path) -> Result<()> {
1498    use std::os::unix::fs::MetadataExt as _;
1499
1500    if metadata.nlink() > 1 {
1501        eyre::bail!("cache export contains a hard link at {}", path.display());
1502    }
1503    Ok(())
1504}
1505
1506/// Windows reports a link count only through `MetadataExt::number_of_links`,
1507/// which is unstable, so this check cannot be made there without reaching for
1508/// the platform API directly. The gap is narrow -- it needs an attacker who
1509/// can already write into the job's filesystem before the import runs -- but
1510/// it is a gap, and a Windows directory bundle does not get this protection.
1511#[cfg(not(unix))]
1512fn reject_linked_file(_metadata: &std::fs::Metadata, _path: &Path) -> Result<()> {
1513    Ok(())
1514}
1515
1516fn validate_archive_path(path: &Path) -> Result<()> {
1517    if path.as_os_str().is_empty()
1518        || path.is_absolute()
1519        || path
1520            .components()
1521            .any(|component| !matches!(component, Component::Normal(_)))
1522    {
1523        eyre::bail!("cache export contains unsafe path {}", path.display());
1524    }
1525    // Compare components rather than a spelling. A tar entry always writes its
1526    // path with forward slashes, but a directory bundle is walked with the
1527    // platform's separator, so on Windows a prefix match against "cas/v1/"
1528    // would reject every file in the bundle.
1529    let parts = path
1530        .components()
1531        .map(|component| component.as_os_str().to_string_lossy().into_owned())
1532        .collect::<Vec<_>>();
1533    let expected = match parts.as_slice() {
1534        [name] => name == EXPORT_MANIFEST,
1535        [root, version, _rest @ ..] => {
1536            parts.len() > 2 && (root == "cas" || root == "action-results") && version == "v1"
1537        }
1538        [] => false,
1539    };
1540    if !expected {
1541        eyre::bail!("cache export contains unexpected path {}", path.display());
1542    }
1543    Ok(())
1544}
1545
1546/// One checkout's claim on the actions a build identity recorded.
1547///
1548/// The target directory is not read back by anything yet; it is recorded
1549/// because it is the other half of what the shim mapped, and a record that
1550/// names only half of it would have to be rewritten to answer where the
1551/// artifacts went.
1552#[derive(Debug, Clone, Serialize, Deserialize)]
1553#[serde(deny_unknown_fields)]
1554struct CheckoutRecord {
1555    version: u8,
1556    workspace_root: PathBuf,
1557    target_dir: PathBuf,
1558    updated_secs: u64,
1559}
1560
1561/// Summarize what the store currently holds.
1562pub fn stats(store: &Path) -> Result<StoreStats> {
1563    let objects = walk_files(&store.join(CAS_DIR))?;
1564    let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
1565    let checkouts = scan_checkouts(store)?;
1566    Ok(StoreStats {
1567        objects: objects.len() as u64,
1568        object_bytes: objects.iter().map(|entry| entry.size).sum(),
1569        action_results: results.len() as u64,
1570        action_result_bytes: results.iter().map(|entry| entry.size).sum(),
1571        live_checkouts: checkouts.live_records,
1572        stale_checkouts: checkouts.stale_records.len() as u64,
1573    })
1574}
1575
1576/// Size the target tree a checkout record names, if it names one.
1577///
1578/// A build with no target directory of its own -- one driven by make or CMake,
1579/// which writes wherever it was told -- records the checkout in that field
1580/// rather than inventing a directory. Walking it would report the whole source
1581/// tree, `.git` included, as though it were build output.
1582fn checkout_target_bytes(sizes: &mut BTreeMap<PathBuf, u64>, record: &CheckoutRecord) -> u64 {
1583    if record.target_dir == record.workspace_root {
1584        return 0;
1585    }
1586    cached_tree_bytes(sizes, &record.target_dir)
1587}
1588
1589/// Attribute cache and target bytes to each recorded workspace.
1590///
1591/// Shared objects are counted for every workspace that can reach them. That
1592/// makes each row answer "how much keeps this workspace warm" without
1593/// pretending shared storage can be divided exactly between projects.
1594pub fn projects(store: &Path) -> Result<Vec<ProjectUsage>> {
1595    project_usage(store, true)
1596}
1597
1598/// The reusable cache bytes of each live workspace, as [`projects`] counts
1599/// them.
1600///
1601/// Sizing target directories is most of what [`projects`] costs on a machine
1602/// with many checkouts, and an estimate of cache sharing never looks at them.
1603pub fn live_project_cache_bytes(store: &Path) -> Result<Vec<u64>> {
1604    Ok(project_usage(store, false)?
1605        .into_iter()
1606        .filter(|project| project.live)
1607        .map(|project| project.action_bytes)
1608        .collect())
1609}
1610
1611fn project_usage(store: &Path, size_targets: bool) -> Result<Vec<ProjectUsage>> {
1612    let mut projects: BTreeMap<PathBuf, (BTreeSet<String>, bool, u64)> = BTreeMap::new();
1613    let mut target_sizes = BTreeMap::new();
1614    let root = store.join(CHECKOUTS_DIR);
1615    for identity_entry in read_dir_or_empty(&root)? {
1616        let identity = identity_entry.file_name().to_string_lossy().into_owned();
1617        if !is_task_identity(&identity) || !identity_entry.file_type()?.is_dir() {
1618            continue;
1619        }
1620        for entry in walk_files(&identity_entry.path())? {
1621            let Some(record) = read_checkout_record(&entry.path) else {
1622                continue;
1623            };
1624            let project = projects.entry(record.workspace_root.clone()).or_default();
1625            if claim_is_live(store, &record) {
1626                project.0.insert(identity.clone());
1627                project.1 = true;
1628            }
1629            if size_targets {
1630                project.2 = project
1631                    .2
1632                    .max(checkout_target_bytes(&mut target_sizes, &record));
1633            }
1634        }
1635    }
1636    let live = projects
1637        .values()
1638        .flat_map(|(identities, _, _)| identities.iter().cloned())
1639        .collect::<BTreeSet<_>>();
1640    let mut reachability = Reachability::new(store);
1641    resolve_identities(&reachability, &live);
1642    let mut usages = Vec::new();
1643    for (workspace_root, (identities, live, target_bytes)) in projects {
1644        let action_bytes = reachability.bytes(&identities);
1645        usages.push(ProjectUsage {
1646            workspace_root,
1647            identities: identities.len() as u64,
1648            action_bytes,
1649            target_bytes,
1650            live,
1651        });
1652    }
1653    usages.sort_by(|left, right| {
1654        right
1655            .action_bytes
1656            .saturating_add(right.target_bytes)
1657            .cmp(&left.action_bytes.saturating_add(left.target_bytes))
1658            .then_with(|| left.workspace_root.cmp(&right.workspace_root))
1659    });
1660    Ok(usages)
1661}
1662
1663/// Resolve every identity's recorded actions into `reachability`.
1664///
1665/// Manifests are the one large input: a busy checkout's runs to tens of
1666/// megabytes of JSON, and parsing hundreds of them one after another is most
1667/// of what sizing every workspace costs. Resolving what they reach is mostly
1668/// cold file reads. Both are spread over a few threads, each taking whole
1669/// identities from a shared counter.
1670fn resolve_identities(reachability: &Reachability, identities: &BTreeSet<String>) {
1671    const MAX_WORKERS: usize = 8;
1672    let identities = identities.iter().collect::<Vec<_>>();
1673    let workers = std::thread::available_parallelism()
1674        .map(usize::from)
1675        .unwrap_or(1)
1676        .min(MAX_WORKERS)
1677        .min(identities.len());
1678    let next = AtomicUsize::new(0);
1679    std::thread::scope(|scope| {
1680        for _ in 0..workers {
1681            scope.spawn(|| {
1682                while let Some(identity) = identities.get(next.fetch_add(1, Ordering::Relaxed)) {
1683                    // One manifest this build cannot read is not worth
1684                    // abandoning the report over. It reaches nothing, which
1685                    // can only understate sharing.
1686                    let actions = task_manifest_actions(&reachability.store, identity)
1687                        .unwrap_or_else(|error| {
1688                            log::debug!("could not read the manifest for {identity}: {error}");
1689                            Vec::new()
1690                        });
1691                    reachability.record(identity, &actions);
1692                }
1693            });
1694        }
1695    });
1696}
1697
1698/// The store paths each identity can reach, resolved once for every workspace.
1699///
1700/// Workspaces share identities, identities share most of their actions, and
1701/// actions share output trees. Answering each workspace from scratch reread
1702/// the same manifests, results, and trees, and stat-ed the same objects, once
1703/// per workspace, which on a machine with a couple of hundred checkouts was
1704/// most of the half minute `mbx stats` took. Here every file is read and
1705/// stat-ed at most once, and a workspace's total is a union of indices already
1706/// in memory.
1707///
1708/// What is reachable matches [`rooted_objects`] plus the action-result records
1709/// themselves, and every read is as tolerant as it is there.
1710struct Reachability {
1711    store: PathBuf,
1712    cas: LocalCas,
1713    action_cache: mbx_cache_core::LocalActionCache,
1714    /// Every indexed path and its size, zero if it no longer exists.
1715    paths: Mutex<PathIndex>,
1716    identities: Mutex<HashMap<String, ActionPaths>>,
1717    actions: Mutex<HashMap<CacheDigest, Arc<[usize]>>>,
1718    /// Each tree blob's child directories and the paths it names directly.
1719    directories: Mutex<HashMap<CacheDigest, Option<DirectoryPaths>>>,
1720    /// The workspace number that last counted each path.
1721    counted: Vec<usize>,
1722    workspaces: usize,
1723}
1724
1725#[derive(Default)]
1726struct PathIndex {
1727    indices: HashMap<PathBuf, usize>,
1728    sizes: Vec<u64>,
1729}
1730
1731/// The paths each of an identity's actions reaches.
1732type ActionPaths = Arc<[Arc<[usize]>]>;
1733
1734type DirectoryPaths = (Arc<[CacheDigest]>, Arc<[usize]>);
1735
1736/// The caches are only guards around maps; a panicked holder leaves them
1737/// consistent, so poisoning is not worth failing a report over.
1738fn locked<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
1739    mutex.lock().unwrap_or_else(|error| error.into_inner())
1740}
1741
1742impl Reachability {
1743    fn new(store: &Path) -> Self {
1744        Self {
1745            store: store.to_path_buf(),
1746            cas: LocalCas::new(store),
1747            action_cache: mbx_cache_core::LocalActionCache::new(store),
1748            paths: Mutex::default(),
1749            identities: Mutex::default(),
1750            actions: Mutex::default(),
1751            directories: Mutex::default(),
1752            counted: Vec::new(),
1753            workspaces: 0,
1754        }
1755    }
1756
1757    /// Bytes reachable from any of `identities`, each path counted once.
1758    ///
1759    /// An identity that was never recorded reaches nothing.
1760    fn bytes(&mut self, identities: &BTreeSet<String>) -> u64 {
1761        let reachable = {
1762            let recorded = locked(&self.identities);
1763            identities
1764                .iter()
1765                .filter_map(|identity| recorded.get(identity).cloned())
1766                .collect::<Vec<_>>()
1767        };
1768        let sizes = std::mem::take(&mut locked(&self.paths).sizes);
1769        self.workspaces += 1;
1770        self.counted.resize(sizes.len(), 0);
1771        let mut bytes = 0_u64;
1772        for index in reachable
1773            .iter()
1774            .flat_map(|actions| actions.iter())
1775            .flat_map(|paths| paths.iter())
1776        {
1777            if self.counted[*index] != self.workspaces {
1778                self.counted[*index] = self.workspaces;
1779                bytes = bytes.saturating_add(sizes[*index]);
1780            }
1781        }
1782        locked(&self.paths).sizes = sizes;
1783        bytes
1784    }
1785
1786    /// Resolve what one identity's recorded actions reach.
1787    ///
1788    /// Safe to call from several threads. Two threads may resolve the same
1789    /// action at once; they read the same files and reach the same answer.
1790    fn record(&self, identity: &str, actions: &[CacheDigest]) {
1791        let actions = actions
1792            .iter()
1793            .map(|action| self.action(action))
1794            .collect::<Arc<[_]>>();
1795        locked(&self.identities).insert(identity.to_string(), actions);
1796    }
1797
1798    fn action(&self, action: &CacheDigest) -> Arc<[usize]> {
1799        if let Some(paths) = locked(&self.actions).get(action) {
1800            return paths.clone();
1801        }
1802        let mut paths = Vec::new();
1803        if let Ok(path) = self.action_cache.path_for(action) {
1804            paths.push(self.index(path));
1805        }
1806        if let Some(result) = read_action_result(&self.store, action) {
1807            self.push_digest(&mut paths, &result.action);
1808            if let Some(metadata) = &result.metadata {
1809                self.push_digest(&mut paths, metadata);
1810                if let Some(rustc) = read_rustc_metadata(&self.cas, metadata) {
1811                    self.push_digest(&mut paths, &rustc.stdout);
1812                    self.push_digest(&mut paths, &rustc.stderr);
1813                }
1814            }
1815            if let Some(output_root) = &result.output_root {
1816                self.push_digest(&mut paths, output_root);
1817                let mut pending = vec![output_root.clone()];
1818                let mut visited = HashSet::new();
1819                while let Some(digest) = pending.pop() {
1820                    if !visited.insert(digest.clone()) {
1821                        continue;
1822                    }
1823                    let Some((children, named)) = self.directory(&digest) else {
1824                        continue;
1825                    };
1826                    paths.extend(named.iter().copied());
1827                    pending.extend(children.iter().cloned());
1828                }
1829            }
1830        }
1831        paths.sort_unstable();
1832        paths.dedup();
1833        let paths = Arc::<[usize]>::from(paths);
1834        locked(&self.actions).insert(action.clone(), paths.clone());
1835        paths
1836    }
1837
1838    /// The child directories of a tree blob and the paths it names directly.
1839    fn directory(&self, digest: &CacheDigest) -> Option<DirectoryPaths> {
1840        if let Some(entry) = locked(&self.directories).get(digest) {
1841            return entry.clone();
1842        }
1843        let entry = read_directory(&self.cas, digest).map(|directory| {
1844            let mut paths = Vec::new();
1845            for file in &directory.files {
1846                self.push_digest(&mut paths, &file.digest);
1847            }
1848            for child in &directory.directories {
1849                self.push_digest(&mut paths, &child.digest);
1850            }
1851            let children = directory
1852                .directories
1853                .into_iter()
1854                .map(|child| child.digest)
1855                .collect::<Arc<[_]>>();
1856            (children, Arc::<[usize]>::from(paths))
1857        });
1858        locked(&self.directories).insert(digest.clone(), entry.clone());
1859        entry
1860    }
1861
1862    fn push_digest(&self, paths: &mut Vec<usize>, digest: &CacheDigest) {
1863        if let Ok(path) = self.cas.path_for(digest) {
1864            paths.push(self.index(path));
1865        }
1866    }
1867
1868    fn index(&self, path: PathBuf) -> usize {
1869        if let Some(index) = locked(&self.paths).indices.get(&path) {
1870            return *index;
1871        }
1872        // The stat runs outside the lock. A second thread may stat the same
1873        // path; whichever registers first wins and both get one index.
1874        let size = std::fs::metadata(&path).map_or(0, |metadata| metadata.len());
1875        let mut paths = locked(&self.paths);
1876        if let Some(index) = paths.indices.get(&path) {
1877            return *index;
1878        }
1879        let index = paths.sizes.len();
1880        paths.sizes.push(size);
1881        paths.indices.insert(path, index);
1882        index
1883    }
1884}
1885
1886/// Return the largest blobs and action-result records in descending order.
1887pub fn largest(store: &Path, limit: usize) -> Result<Vec<LargestEntry>> {
1888    let mut entries = Vec::new();
1889    for (kind, root, action_result) in [
1890        ("object", store.join(CAS_DIR), false),
1891        ("action result", store.join(ACTION_RESULTS_DIR), true),
1892    ] {
1893        entries.extend(
1894            walk_files(&root)?
1895                .into_iter()
1896                .filter(|entry| addressed_digest(store, &entry.path, action_result).is_some())
1897                .map(|entry| LargestEntry {
1898                    kind,
1899                    path: entry.path,
1900                    bytes: entry.size,
1901                }),
1902        );
1903    }
1904    entries.sort_by(|left, right| {
1905        right
1906            .bytes
1907            .cmp(&left.bytes)
1908            .then_with(|| left.path.cmp(&right.path))
1909    });
1910    entries.truncate(limit);
1911    Ok(entries)
1912}
1913
1914/// Verify every addressable CAS object and action-result record.
1915pub fn verify(store: &Path) -> Result<VerifyOutcome> {
1916    let cas = LocalCas::new(store);
1917    let action_cache = mbx_cache_core::LocalActionCache::new(store);
1918    // Verification reports the store as it is on disk and removes nothing, so
1919    // no object is pending removal the way one is mid-sweep.
1920    let virtually_removed = HashSet::new();
1921    let mut outcome = VerifyOutcome::default();
1922    let mut trees = HashMap::new();
1923    for entry in walk_files(&store.join(CAS_DIR))? {
1924        let Some(digest) = addressed_digest(store, &entry.path, false) else {
1925            continue;
1926        };
1927        outcome.checked_objects += 1;
1928        if !matches!(cas.find(&digest), Ok(Some(_))) {
1929            outcome.problems.push(entry.path);
1930        }
1931    }
1932    for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
1933        let Some(digest) = addressed_digest(store, &entry.path, true) else {
1934            continue;
1935        };
1936        outcome.checked_action_results += 1;
1937        if !matches!(action_cache.find(&digest), Ok(Some(_)))
1938            || action_result_is_dangling(&cas, &entry.path, &virtually_removed, true, &mut trees)?
1939        {
1940            outcome.problems.push(entry.path);
1941        }
1942    }
1943    outcome.problems.sort();
1944    Ok(outcome)
1945}
1946
1947/// Remove checkout claims belonging to exactly one workspace.
1948pub fn remove_project(store: &Path, workspace_root: &Path) -> Result<RemoveProjectOutcome> {
1949    let mut outcome = RemoveProjectOutcome::default();
1950    for entry in walk_files(&store.join(CHECKOUTS_DIR))? {
1951        if read_checkout_record(&entry.path)
1952            .is_some_and(|record| record.workspace_root == workspace_root)
1953        {
1954            std::fs::remove_file(&entry.path)?;
1955            outcome.removed_checkout_records += 1;
1956            if let Some(parent) = entry.path.parent() {
1957                let _ = std::fs::remove_dir(parent);
1958            }
1959        }
1960    }
1961    match std::fs::remove_file(latest_receipt_path(store, workspace_root)) {
1962        Ok(()) => {}
1963        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1964        Err(error) => return Err(error.into()),
1965    }
1966    Ok(outcome)
1967}
1968
1969/// Record that `workspace_root` built `identity`.
1970///
1971/// Identities are shared by every checkout of one dependency graph, so this is
1972/// what later tells the collector whether anything still needs what a build
1973/// cached: one file per checkout under the identity it built. Writing a whole
1974/// file per checkout rather than merging a list into one keeps concurrent
1975/// builds out of each other's way -- there is nothing to merge, so there is no
1976/// lock and no lost update.
1977///
1978/// A build with no target directory of its own passes `workspace_root` for
1979/// `target_dir`, which reads as "none" rather than as a tree to measure.
1980pub fn record_checkout(
1981    store: &Path,
1982    identity: &str,
1983    workspace_root: &Path,
1984    target_dir: &Path,
1985) -> Result<()> {
1986    let record = CheckoutRecord {
1987        version: CHECKOUT_RECORD_VERSION,
1988        workspace_root: workspace_root.to_path_buf(),
1989        target_dir: target_dir.to_path_buf(),
1990        updated_secs: SystemTime::now()
1991            .duration_since(UNIX_EPOCH)
1992            .map(|since| since.as_secs())
1993            .unwrap_or_default(),
1994    };
1995    let mut contents = serde_json::to_vec(&record)?;
1996    contents.push(b'\n');
1997    // Refreshed by every build and read back only as a last-used stamp, so a
1998    // power cut costs one build's worth of recency, not a wait on the disk.
1999    write_advisory(
2000        &checkout_record_path(store, identity, workspace_root),
2001        &contents,
2002    )
2003}
2004
2005fn checkout_record_path(store: &Path, identity: &str, workspace_root: &Path) -> PathBuf {
2006    // The path is hashed rather than escaped: it is only ever compared against
2007    // the same hash of the same path, and every filesystem in play disagrees
2008    // about which characters a name may hold.
2009    let key = CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash;
2010    store
2011        .join(CHECKOUTS_DIR)
2012        .join(identity)
2013        .join(format!("{key}.json"))
2014}
2015
2016/// Evict objects until the store fits within `max_bytes`.
2017///
2018/// Objects no live checkout can reach are evicted first, so deleting a worktree
2019/// releases what only that worktree needed. Within each class eviction is
2020/// least-recently-used by access time where the filesystem records it, falling
2021/// back to modification time. A restore verifies the blob it serves, which
2022/// means it reads the whole file and the access time is real -- but only where
2023/// the mount records one, so the ordering is an approximation either way.
2024/// Evicting a live object costs a recompile, never correctness.
2025pub fn gc(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
2026    gc_with_mode(store, max_bytes, false, HashSet::new())
2027}
2028
2029/// Describe the collection `gc` would perform without changing the store.
2030pub fn gc_dry_run(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
2031    gc_with_mode(store, max_bytes, true, HashSet::new())
2032}
2033
2034#[cfg(test)]
2035type DanglingReadHook = Box<dyn FnMut(&Path)>;
2036
2037#[cfg(test)]
2038thread_local! {
2039    static ACTION_RECORD_CLAIMS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
2040    /// Runs after collection finds a record dangling and before it claims it.
2041    static AFTER_DANGLING_READ: std::cell::RefCell<Option<DanglingReadHook>> =
2042        const { std::cell::RefCell::new(None) };
2043}
2044
2045/// `already_removed` names paths an earlier dry-run pass planned to delete;
2046/// they are treated as gone, so the two plans describe one collection.
2047fn gc_with_mode(
2048    store: &Path,
2049    max_bytes: u64,
2050    dry_run: bool,
2051    already_removed: HashSet<PathBuf>,
2052) -> Result<GcOutcome> {
2053    let mut objects = walk_files(&store.join(CAS_DIR))?;
2054    let mut results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
2055    objects.retain(|entry| !already_removed.contains(&entry.path));
2056    results.retain(|entry| !already_removed.contains(&entry.path));
2057    let mut live_bytes = objects
2058        .iter()
2059        .chain(results.iter())
2060        .map(|entry| entry.size)
2061        .sum::<u64>();
2062
2063    let mut outcome = GcOutcome::default();
2064
2065    // Prune before deciding what is rooted, so a checkout deleted since the
2066    // last sweep stops protecting its artifacts during this one.
2067    let checkouts = scan_checkouts(store)?;
2068    for path in &checkouts.stale_records {
2069        if dry_run || matches!(remove(path)?, Removal::Removed) {
2070            outcome.removed_checkout_records += 1;
2071        }
2072        // The identity directory is left behind empty otherwise. It may hold
2073        // other checkouts, in which case this fails and that is the answer.
2074        if !dry_run && let Some(parent) = path.parent() {
2075            let _ = std::fs::remove_dir(parent);
2076        }
2077    }
2078
2079    let manifests = prune_task_manifests(store, &checkouts.live_identities, dry_run);
2080    outcome.removed_task_manifests += manifests.removed_manifests;
2081    outcome.removed_bytes += manifests.removed_bytes;
2082
2083    let mut virtually_removed = already_removed;
2084    if live_bytes > max_bytes {
2085        // Reachability costs a read per action result and per output tree, so
2086        // it is computed only when something is actually about to be evicted.
2087        // Under budget a sweep stays a directory walk and nothing more.
2088        let mut rooted = rooted_objects(store, &checkouts.live_identities)?;
2089        // A grouped CI export deliberately retains the exact actions from
2090        // every command in the job. Later commands can replace the current
2091        // task-manifest prediction for the same invocation, but the earlier
2092        // receipt still has to remain exportable until the post-job step runs.
2093        // Receipt bookkeeping is not part of the cache budget, so consult it
2094        // only when a sweep is already necessary and root its object closure.
2095        rooted.extend(rooted_action_objects(
2096            store,
2097            grouped_receipt_actions(store)?,
2098        ));
2099        // `false` sorts first, so unrooted objects go before rooted ones and
2100        // each class goes oldest-first. A store with no records at all roots
2101        // nothing and this is exactly the LRU it was before.
2102        objects.sort_by_cached_key(|entry| (rooted.contains(&entry.path), entry.used));
2103        let evicted = evict_objects(&objects, &rooted, live_bytes, max_bytes, |path| {
2104            if dry_run {
2105                virtually_removed.insert(path.to_path_buf());
2106                Ok(Removal::Removed)
2107            } else {
2108                remove(path)
2109            }
2110        })?;
2111        live_bytes = evicted.remaining_bytes;
2112        outcome.removed_objects += evicted.removed_objects;
2113        outcome.removed_bytes += evicted.removed_bytes;
2114    }
2115
2116    // An action result whose objects are gone can only produce a miss, so drop
2117    // it rather than leave the index pointing at nothing. This runs whether or
2118    // not this call evicted anything, since another process may have, and a
2119    // store that is over budget on results alone still needs the sweep.
2120    //
2121    // Walking each result's output tree costs a read per tree blob and a stat
2122    // per file, so it is done only when this sweep evicted something. That is
2123    // the moment a tree can have lost a file while its own object survived.
2124    //
2125    // Only this call's own evictions count, in a dry run too: objects a stale
2126    // pass planned to drop are accounted for by that pass, and a real run has
2127    // already deleted them, so neither makes this sweep walk trees.
2128    let walk_trees = outcome.removed_objects > 0;
2129    let cas = LocalCas::new(store);
2130    let mut trees = HashMap::new();
2131    for entry in &results {
2132        match action_result_is_dangling(
2133            &cas,
2134            &entry.path,
2135            &virtually_removed,
2136            walk_trees,
2137            &mut trees,
2138        ) {
2139            Ok(false) => continue,
2140            Ok(true) => {}
2141            // Windows readers can encounter another repair's mandatory lock.
2142            Err(error)
2143                if error
2144                    .chain()
2145                    .filter_map(|cause| cause.downcast_ref::<std::io::Error>())
2146                    .any(held_by_another_process) =>
2147            {
2148                log::debug!(
2149                    "could not inspect {} for eviction: {error}",
2150                    entry.path.display()
2151                );
2152                continue;
2153            }
2154            Err(error) => return Err(error),
2155        }
2156        let removal = if dry_run {
2157            Removal::Removed
2158        } else {
2159            #[cfg(test)]
2160            AFTER_DANGLING_READ.with(|hook| {
2161                if let Some(hook) = hook.borrow_mut().as_mut() {
2162                    hook(&entry.path);
2163                }
2164            });
2165            #[cfg(test)]
2166            ACTION_RECORD_CLAIMS.with(|count| count.set(count.get() + 1));
2167            match ActionSlot::try_claim(&entry.path) {
2168                Err(error) if held_by_another_process(&error) => {
2169                    log::debug!(
2170                        "could not claim {} for eviction: {error}",
2171                        entry.path.display()
2172                    );
2173                    Removal::Blocked
2174                }
2175                Err(error) => return Err(error.into()),
2176                Ok(None) => Removal::Blocked,
2177                Ok(Some(slot)) => match slot.read()? {
2178                    None => Removal::Missing,
2179                    Some(bytes) => {
2180                        // A repair may have restored a missing blob since the filter
2181                        // ran, so do not reuse its cached tree verdicts.
2182                        if !action_result_bytes_are_dangling(
2183                            &cas,
2184                            &bytes,
2185                            &virtually_removed,
2186                            walk_trees,
2187                            &mut HashMap::new(),
2188                        )? {
2189                            continue;
2190                        }
2191                        match slot.remove() {
2192                            Ok(true) => Removal::Removed,
2193                            Ok(false) => Removal::Missing,
2194                            // Open files may prevent deletion on Windows, just
2195                            // as they do in the other eviction paths.
2196                            Err(error) if held_by_another_process(&error) => {
2197                                log::debug!("could not evict {}: {error}", entry.path.display());
2198                                Removal::Blocked
2199                            }
2200                            Err(error) => {
2201                                return Err(error).wrap_err_with(|| {
2202                                    format!("failed to evict {}", entry.path.display())
2203                                });
2204                            }
2205                        }
2206                    }
2207                },
2208            }
2209        };
2210        match removal {
2211            Removal::Removed => {
2212                live_bytes = live_bytes.saturating_sub(entry.size);
2213                outcome.removed_action_results += 1;
2214                outcome.removed_bytes += entry.size;
2215            }
2216            Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
2217            Removal::Blocked => {}
2218        }
2219    }
2220
2221    let sessions = prune_sessions(store, dry_run)?;
2222    outcome.removed_session_streams += sessions.removed_streams;
2223    outcome.removed_bytes += sessions.removed_bytes;
2224    prune_import_staging(store, dry_run);
2225
2226    outcome.remaining_bytes = live_bytes;
2227    Ok(outcome)
2228}
2229
2230/// Drop cached compilations for Rust compiler versions that have gone unused.
2231///
2232/// A new compiler never reuses what an older one cached, so after an upgrade
2233/// the old version's results only occupy disk. Each action result is
2234/// attributed to the compiler version recorded in its action descriptor. A
2235/// version counts as in use while any of its results was used within
2236/// `max_age`; the rest are removed together with the objects only they
2237/// reach. The most recently used version is always kept, so a store that has
2238/// sat idle for longer than `max_age` is not emptied wholesale.
2239///
2240/// Results that cannot be attributed (other languages' actions, unreadable
2241/// descriptors) are never touched. Use is read from the result file's access
2242/// time where the filesystem records it, else its modification time.
2243pub fn gc_stale_compilers(store: &Path, max_age: Duration, dry_run: bool) -> Result<GcOutcome> {
2244    gc_compilers(
2245        store,
2246        &CompilerSelection {
2247            max_age: Some(max_age),
2248            release: None,
2249        },
2250        dry_run,
2251    )
2252}
2253
2254/// Which compiler versions' cached results a pass removes.
2255#[derive(Debug, Clone, Default)]
2256pub struct CompilerSelection {
2257    /// Remove versions unused for this long, always keeping the newest.
2258    pub max_age: Option<Duration>,
2259    /// Remove every result of this compiler, however recently it was used.
2260    ///
2261    /// Matches the release a compiler reports, such as `1.97.1` or
2262    /// `1.99.0-nightly`, or the whole version line, such as
2263    /// `1.99.0-nightly (abc123def 2026-09-01)`, which tells two nightlies of
2264    /// one release apart. Unlike `max_age`, nothing is exempt: naming the
2265    /// newest version removes it.
2266    pub release: Option<String>,
2267}
2268
2269/// Drop cached compilations for the compilers `selection` names.
2270///
2271/// See [`gc_stale_compilers`] for how results are attributed and which are
2272/// never touched.
2273pub fn gc_compilers(
2274    store: &Path,
2275    selection: &CompilerSelection,
2276    dry_run: bool,
2277) -> Result<GcOutcome> {
2278    stale_compilers_pass(store, selection, dry_run).map(|(outcome, _)| outcome)
2279}
2280
2281/// Describe what `gc_stale_compilers` followed by `gc_dry_run` would remove,
2282/// counting nothing twice and measuring the budget after the stale pass.
2283pub fn gc_dry_run_after_stale(
2284    store: &Path,
2285    max_bytes: u64,
2286    max_age: Duration,
2287) -> Result<GcOutcome> {
2288    gc_dry_run_after_compilers(
2289        store,
2290        max_bytes,
2291        &CompilerSelection {
2292            max_age: Some(max_age),
2293            release: None,
2294        },
2295    )
2296}
2297
2298/// Describe what `gc_compilers` followed by `gc_dry_run` would remove,
2299/// counting nothing twice and measuring the budget after the compiler pass.
2300pub fn gc_dry_run_after_compilers(
2301    store: &Path,
2302    max_bytes: u64,
2303    selection: &CompilerSelection,
2304) -> Result<GcOutcome> {
2305    let (mut stale, planned) = stale_compilers_pass(store, selection, true)?;
2306    let budget = gc_with_mode(store, max_bytes, true, planned)?;
2307    stale.removed_objects += budget.removed_objects;
2308    stale.removed_action_results += budget.removed_action_results;
2309    stale.removed_checkout_records += budget.removed_checkout_records;
2310    stale.removed_task_manifests += budget.removed_task_manifests;
2311    stale.removed_session_streams += budget.removed_session_streams;
2312    stale.removed_bytes += budget.removed_bytes;
2313    stale.remaining_bytes = budget.remaining_bytes;
2314    Ok(stale)
2315}
2316
2317/// The paths a pass removed, or in a dry run would remove.
2318fn stale_compilers_pass(
2319    store: &Path,
2320    selection: &CompilerSelection,
2321    dry_run: bool,
2322) -> Result<(GcOutcome, HashSet<PathBuf>)> {
2323    struct Attributed {
2324        entry: Entry,
2325        action: CacheDigest,
2326    }
2327
2328    let cas = LocalCas::new(store);
2329    let mut by_version: BTreeMap<String, Vec<Attributed>> = BTreeMap::new();
2330    let mut unattributed = Vec::new();
2331    for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
2332        let result = read_result_untouched(&entry.path)
2333            .ok()
2334            .and_then(|bytes| serde_json::from_slice::<RemoteActionResult>(&bytes).ok());
2335        let Some(result) = result else {
2336            continue;
2337        };
2338        match rustc_version_of(&cas, &result.action) {
2339            Some(version) => by_version.entry(version).or_default().push(Attributed {
2340                entry,
2341                action: result.action,
2342            }),
2343            None => unattributed.push(result.action),
2344        }
2345    }
2346
2347    let last_used = |results: &[Attributed]| results.iter().map(|r| r.entry.used).max();
2348    let newest = by_version
2349        .iter()
2350        .max_by_key(|(_, results)| last_used(results))
2351        .map(|(version, _)| version.clone());
2352    let cutoff = selection.max_age.map(|max_age| {
2353        SystemTime::now()
2354            .checked_sub(max_age)
2355            .unwrap_or(SystemTime::UNIX_EPOCH)
2356    });
2357    let mut outcome = GcOutcome::default();
2358    let mut stale = Vec::new();
2359    let mut kept_actions = unattributed;
2360    for (version, results) in by_version {
2361        let idle = cutoff
2362            .zip(last_used(&results))
2363            .is_some_and(|(cutoff, used)| used < cutoff)
2364            && Some(&version) != newest.as_ref();
2365        let named = selection
2366            .release
2367            .as_deref()
2368            .is_some_and(|release| names_compiler(release, &version));
2369        if idle || named {
2370            stale.extend(results);
2371        } else {
2372            kept_actions.extend(results.into_iter().map(|r| r.action));
2373        }
2374    }
2375    // A grouped CI export deliberately retains its actions until the post-job
2376    // step runs, whatever compiler made them. Those are never stale.
2377    let pending = grouped_receipt_actions(store)?;
2378    let (pinned, stale): (Vec<_>, Vec<_>) =
2379        stale.into_iter().partition(|r| pending.contains(&r.action));
2380    kept_actions.extend(pinned.into_iter().map(|r| r.action));
2381    let mut planned = HashSet::new();
2382    if stale.is_empty() {
2383        return Ok((outcome, planned));
2384    }
2385
2386    let doomed = rooted_action_objects(store, stale.iter().map(|r| r.action.clone()));
2387    let kept = rooted_action_objects(store, kept_actions);
2388    for result in &stale {
2389        if dry_run || matches!(remove(&result.entry.path)?, Removal::Removed) {
2390            outcome.removed_action_results += 1;
2391            outcome.removed_bytes += result.entry.size;
2392            planned.insert(result.entry.path.clone());
2393        }
2394    }
2395    for path in doomed.difference(&kept) {
2396        let Ok(metadata) = std::fs::metadata(path) else {
2397            continue;
2398        };
2399        if dry_run || matches!(remove(path)?, Removal::Removed) {
2400            outcome.removed_objects += 1;
2401            outcome.removed_bytes += metadata.len();
2402            planned.insert(path.clone());
2403        }
2404    }
2405    Ok((outcome, planned))
2406}
2407
2408/// Read a cache file without advancing the access time sweeps treat as use.
2409///
2410/// Maintenance passes read every result. Where the filesystem records access
2411/// times, that would make each sweep look like a use and an idle compiler
2412/// version would never age out. On Linux the file is opened with `O_NOATIME`,
2413/// which leaves the time alone without touching it afterwards, so a build's
2414/// concurrent hit is never overwritten. Elsewhere, or when the kernel refuses
2415/// the flag for a file the caller does not own, the time seen before the read
2416/// is put back; that can lose a hit that lands inside the read.
2417fn read_result_untouched(path: &Path) -> std::io::Result<Vec<u8>> {
2418    #[cfg(target_os = "linux")]
2419    {
2420        use std::io::Read as _;
2421        use std::os::unix::fs::OpenOptionsExt as _;
2422        if let Ok(mut file) = std::fs::OpenOptions::new()
2423            .read(true)
2424            .custom_flags(libc::O_NOATIME)
2425            .open(path)
2426        {
2427            let mut bytes = Vec::new();
2428            file.read_to_end(&mut bytes)?;
2429            return Ok(bytes);
2430        }
2431    }
2432    let before = std::fs::metadata(path)?;
2433    let bytes = std::fs::read(path)?;
2434    let _ = filetime::set_file_atime(path, filetime::FileTime::from_last_access_time(&before));
2435    Ok(bytes)
2436}
2437
2438/// Whether `wanted` names the compiler a descriptor recorded as `version`.
2439///
2440/// `version` is the identity's `rustc ...` line followed by `; `-joined build
2441/// details. `wanted` matches the release (the first word after `rustc`) or the
2442/// whole first line, with or without the `rustc ` prefix.
2443fn names_compiler(wanted: &str, version: &str) -> bool {
2444    let wanted = wanted.trim();
2445    let wanted = wanted.strip_prefix("rustc ").unwrap_or(wanted);
2446    if wanted.is_empty() {
2447        return false;
2448    }
2449    let line = version.split(';').next().unwrap_or(version).trim();
2450    let line = line.strip_prefix("rustc ").unwrap_or(line);
2451    line == wanted || line.split_whitespace().next() == Some(wanted)
2452}
2453
2454/// The compiler version a rustc action descriptor was keyed under.
2455fn rustc_version_of(cas: &LocalCas, action: &CacheDigest) -> Option<String> {
2456    let bytes = read_result_untouched(&cas.path_for(action).ok()?).ok()?;
2457    let descriptor: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
2458    if descriptor.get("kind")?.as_str()? != "rustc" {
2459        return None;
2460    }
2461    Some(
2462        descriptor
2463            .pointer("/compiler/rustc_version")?
2464            .as_str()?
2465            .to_owned(),
2466    )
2467}
2468
2469/// Discard import staging trees that a killed process left behind.
2470///
2471/// A finished import removes its own tree, and a failed one unwinds through
2472/// `TempDir`, so only a kill leaks. What leaks is the whole expanded export,
2473/// and it sits outside `cas/v1` and `action-results/v1`, so the sweep's own
2474/// accounting cannot see it and nothing else would ever reclaim it.
2475///
2476/// Age is what makes this safe to do without a lock: a concurrent import
2477/// holds no claim this could consult, and one that has been extracting for a
2478/// day has worse problems than a stale directory. Failures are logged rather
2479/// than returned, because losing a staging tree must not fail a sweep.
2480fn prune_import_staging(store: &Path, dry_run: bool) {
2481    if dry_run {
2482        return;
2483    }
2484    let root = store.join(IMPORT_STAGING_DIR);
2485    let Ok(entries) = read_dir_or_empty(&root) else {
2486        return;
2487    };
2488    for entry in entries {
2489        let Ok(metadata) = entry.metadata() else {
2490            continue;
2491        };
2492        let abandoned = metadata
2493            .modified()
2494            .ok()
2495            .and_then(|modified| SystemTime::now().duration_since(modified).ok())
2496            .is_some_and(|age| age >= IMPORT_STAGING_RETENTION);
2497        if !abandoned {
2498            continue;
2499        }
2500        let path = entry.path();
2501        if !metadata.is_dir() {
2502            // A claim whose tree is already gone: reclaim it so claims cannot
2503            // pile up one per import that was killed after its tree went.
2504            if path
2505                .extension()
2506                .is_some_and(|extension| extension == "lock")
2507                && !claimed_staging_path(&path).exists()
2508            {
2509                let _ = std::fs::remove_file(&path);
2510            }
2511            continue;
2512        }
2513        // Age alone would be a guess. An import that outlives the retention
2514        // window -- a very large bundle on slow storage -- still holds its
2515        // lock, and taking its tree out from under it would fail a running
2516        // restore. A lock nobody holds is one the kernel released for a
2517        // process that is gone.
2518        let lock_path = import_staging_lock_path(&path);
2519        let Ok(mut lock) = fslock::LockFile::open(&lock_path) else {
2520            continue;
2521        };
2522        if !matches!(lock.try_lock(), Ok(true)) {
2523            continue;
2524        }
2525        if let Err(error) = std::fs::remove_dir_all(&path) {
2526            log::debug!(
2527                "could not remove abandoned import staging {}: {error}",
2528                path.display()
2529            );
2530            continue;
2531        }
2532        drop(lock);
2533        let _ = std::fs::remove_file(&lock_path);
2534    }
2535}
2536
2537/// The staging tree a claim describes.
2538fn claimed_staging_path(lock: &Path) -> PathBuf {
2539    lock.with_extension("")
2540}
2541
2542/// Where the liveness lock for one staging tree lives.
2543///
2544/// A sibling of the tree rather than a file inside it, so that removing the
2545/// tree does not remove the lock the remover is holding.
2546fn import_staging_lock_path(staging: &Path) -> PathBuf {
2547    let mut name = staging.as_os_str().to_os_string();
2548    name.push(".lock");
2549    PathBuf::from(name)
2550}
2551
2552/// One import's claim on its staging tree, released when the import ends.
2553///
2554/// The lock file is removed with the claim. `TempDir` takes the tree itself,
2555/// and a claim left behind would otherwise outlive every tree it described:
2556/// the sweep walks directories, so nothing would ever collect it.
2557struct StagingClaim {
2558    lock: Option<fslock::LockFile>,
2559    path: PathBuf,
2560}
2561
2562impl Drop for StagingClaim {
2563    fn drop(&mut self) {
2564        // Release before unlinking, so a sweep that already opened this file
2565        // sees an unlocked claim rather than waiting on one that is going away.
2566        drop(self.lock.take());
2567        let _ = std::fs::remove_file(&self.path);
2568    }
2569}
2570
2571/// Claim a staging tree for the lifetime of one import.
2572fn lock_import_staging(staging: &Path) -> Result<StagingClaim> {
2573    let path = import_staging_lock_path(staging);
2574    let mut lock = fslock::LockFile::open(&path)
2575        .wrap_err_with(|| format!("failed to open {}", path.display()))?;
2576    lock.lock()?;
2577    Ok(StagingClaim {
2578        lock: Some(lock),
2579        path,
2580    })
2581}
2582
2583#[derive(Debug, Default, PartialEq, Eq)]
2584struct SessionPrune {
2585    removed_streams: u64,
2586    removed_bytes: u64,
2587}
2588
2589fn prune_sessions(store: &Path, dry_run: bool) -> Result<SessionPrune> {
2590    let mut prune = SessionPrune::default();
2591    let ids = events::session_ids(store);
2592    let surplus = ids.len().saturating_sub(MAX_SESSIONS);
2593    for (index, id) in ids.iter().enumerate() {
2594        let paths = events::session_paths(store, id);
2595        let metadata = match std::fs::metadata(&paths.events) {
2596            Ok(metadata) => metadata,
2597            Err(_) => continue,
2598        };
2599        let stale = metadata
2600            .modified()
2601            .ok()
2602            .and_then(|at| SystemTime::now().duration_since(at).ok())
2603            .is_some_and(|age| age > SESSION_RETENTION);
2604        if index >= surplus && !stale {
2605            continue;
2606        }
2607        if events::session_is_live(store, id) {
2608            continue;
2609        }
2610        if dry_run {
2611            prune.removed_streams += 1;
2612            prune.removed_bytes += metadata.len();
2613            continue;
2614        }
2615        if matches!(remove(&paths.events)?, Removal::Removed) {
2616            prune.removed_streams += 1;
2617            prune.removed_bytes += metadata.len();
2618            let _ = std::fs::remove_file(&paths.lock);
2619        }
2620    }
2621    if !dry_run {
2622        for lock in events::orphaned_locks(store) {
2623            let _ = std::fs::remove_file(lock);
2624        }
2625    }
2626    Ok(prune)
2627}
2628
2629/// Sweep the store if `interval` has passed since the last attempt.
2630///
2631/// This convenience API claims the interval before running GC, so an
2632/// interrupted attempt waits a full interval. The CLI uses its collector lock
2633/// and [`record_sweep`] after completion instead: collectors killed when a
2634/// sandboxed build exits must leave the next build free to retry.
2635pub fn sweep_if_due(store: &Path, max_bytes: u64, interval: Duration) -> Result<Option<GcOutcome>> {
2636    if !claim_sweep(store, interval)? {
2637        return Ok(None);
2638    }
2639    gc(store, max_bytes).map(Some)
2640}
2641
2642/// Claim a due sweep before doing work, including interrupted attempts.
2643///
2644/// Callers that hold a collector lock and need to retry killed collectors can
2645/// instead check [`sweep_is_due`] under that lock and [`record_sweep`] afterwards.
2646///
2647/// The sweep lock serializes claims, so updating the existing marker in place
2648/// is enough. In particular, it does not need a temporary file and rename:
2649/// claiming a sweep must still work when the disk has no room for one.
2650pub fn claim_sweep(store: &Path, interval: Duration) -> Result<bool> {
2651    let lock_path = store.join(SWEEP_LOCK);
2652    std::fs::create_dir_all(lock_path.parent().expect("sweep lock has a parent"))?;
2653    let mut lock = fslock::LockFile::open(&lock_path)?;
2654    lock.lock()?;
2655
2656    let stamp = store.join(SWEEP_STAMP);
2657    if sweep_stamp_is_fresh(&stamp, interval) {
2658        return Ok(false);
2659    }
2660    record_sweep(store)?;
2661    Ok(true)
2662}
2663
2664/// Record a finished sweep at the current time.
2665///
2666/// The caller must serialize collectors and re-check [`sweep_is_due`] under
2667/// its lock. Update the marker in place, without truncation or a temporary
2668/// file, so an existing marker can still be refreshed on a full disk.
2669pub fn record_sweep(store: &Path) -> Result<()> {
2670    let path = store.join(SWEEP_STAMP);
2671    std::fs::create_dir_all(path.parent().expect("the sweep stamp has a parent"))?;
2672    let stamp = std::fs::OpenOptions::new()
2673        .create(true)
2674        .write(true)
2675        .truncate(false)
2676        .open(path)?;
2677    stamp.set_modified(SystemTime::now())?;
2678    Ok(())
2679}
2680
2681/// Whether the interval since the last recorded sweep has passed.
2682///
2683/// A read of the stamp, no lock and no write: this answers a build that only
2684/// wants to know whether to start a collector, and may be answered yes to two
2685/// builds at once. A collector must re-check this under its collector lock
2686/// and record completion with [`record_sweep`] before releasing the lock.
2687pub fn sweep_is_due(store: &Path, interval: Duration) -> bool {
2688    !sweep_stamp_is_fresh(&store.join(SWEEP_STAMP), interval)
2689}
2690
2691fn sweep_stamp_is_fresh(stamp: &Path, interval: Duration) -> bool {
2692    std::fs::metadata(stamp)
2693        .and_then(|metadata| metadata.modified())
2694        .ok()
2695        .and_then(|modified| modified.elapsed().ok())
2696        .is_some_and(|since| since < interval)
2697}
2698
2699/// What the checkout registry says about the identities in this store.
2700struct CheckoutScan {
2701    live_identities: BTreeSet<String>,
2702    live_records: u64,
2703    stale_records: Vec<PathBuf>,
2704}
2705
2706fn scan_checkouts(store: &Path) -> Result<CheckoutScan> {
2707    let root = store.join(CHECKOUTS_DIR);
2708    let mut scan = CheckoutScan {
2709        live_identities: BTreeSet::new(),
2710        live_records: 0,
2711        stale_records: Vec::new(),
2712    };
2713    let listing = match std::fs::read_dir(&root) {
2714        Ok(listing) => listing,
2715        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(scan),
2716        Err(error) => {
2717            return Err(error).wrap_err_with(|| format!("failed to read {}", root.display()));
2718        }
2719    };
2720    for entry in listing {
2721        let entry = entry?;
2722        let name = entry.file_name().to_string_lossy().into_owned();
2723        // Anything that is not a directory named for an identity was not written
2724        // by mbx. Both halves matter: `walk_files` tolerates only a missing
2725        // directory, so a plain file whose name happens to look like an identity
2726        // would fail the whole scan -- and that scan runs inside every sweep,
2727        // including the automatic one after a build.
2728        if !is_task_identity(&name) || !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
2729            continue;
2730        }
2731        for record in walk_files(&entry.path())? {
2732            match read_checkout_record(&record.path) {
2733                Some(checkout) if claim_is_live(store, &checkout) => {
2734                    scan.live_identities.insert(name.clone());
2735                    scan.live_records += 1;
2736                }
2737                Some(_) => scan.stale_records.push(record.path),
2738                // A record this build cannot read claims nothing, but it is not
2739                // evidence the checkout is gone either, so leave it alone.
2740                None => {}
2741            }
2742        }
2743    }
2744    Ok(scan)
2745}
2746
2747fn read_checkout_record(path: &Path) -> Option<CheckoutRecord> {
2748    let bytes = std::fs::read(path).ok()?;
2749    let record = serde_json::from_slice::<CheckoutRecord>(&bytes).ok()?;
2750    (record.version == CHECKOUT_RECORD_VERSION).then_some(record)
2751}
2752
2753/// Actions whose successful grouped export has not retired its receipts yet.
2754fn grouped_receipt_actions(store: &Path) -> Result<BTreeSet<CacheDigest>> {
2755    let root = store.join(BUILD_RECEIPTS_DIR).join("groups");
2756    Ok(walk_files(&root)?
2757        .into_iter()
2758        .filter_map(|entry| read_build_receipt(&entry.path))
2759        .flat_map(|receipt| receipt.predictions.into_iter())
2760        .map(|prediction| prediction.action)
2761        .collect())
2762}
2763
2764/// Whether a recorded claim still speaks for a checkout that is using this
2765/// store.
2766fn claim_is_live(store: &Path, record: &CheckoutRecord) -> bool {
2767    checkout_is_live_on(store, &record.workspace_root) && !claim_has_expired(record)
2768}
2769
2770fn claim_has_expired(record: &CheckoutRecord) -> bool {
2771    let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
2772        return false;
2773    };
2774    now.as_secs()
2775        .saturating_sub(record.updated_secs)
2776        .gt(&CHECKOUT_RETENTION.as_secs())
2777}
2778
2779/// Whether the checkout a record names is still on disk.
2780///
2781/// Absence is believed only when the checkout is definitely absent and its
2782/// nearest existing ancestor is on the same filesystem as the store. Walking
2783/// all the way to that ancestor matters for worktree managers and temporary
2784/// directories, which commonly remove a checkout together with one or more of
2785/// its otherwise-empty parents.
2786///
2787/// A different filesystem can be a mount whose contents are temporarily
2788/// unavailable, so uncertainty there remains live. Errors are treated the same
2789/// way: being wrong in that direction only delays collection; the other way
2790/// round throws away a warm cache someone is still using.
2791pub fn checkout_is_live_on(store: &Path, workspace_root: &Path) -> bool {
2792    if !matches!(workspace_root.try_exists(), Ok(false)) {
2793        return true;
2794    }
2795
2796    let Ok(store_metadata) = std::fs::metadata(store) else {
2797        return true;
2798    };
2799    for ancestor in workspace_root.ancestors().skip(1) {
2800        match std::fs::metadata(ancestor) {
2801            Ok(ancestor_metadata) => {
2802                return !same_filesystem(store, &store_metadata, ancestor, &ancestor_metadata);
2803            }
2804            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2805            Err(_) => return true,
2806        }
2807    }
2808    true
2809}
2810
2811/// Whether a checkout is still on disk, corroborating absence through its
2812/// immediate parent.
2813///
2814/// Prefer [`checkout_is_live_on`] when an existing store or managed-target
2815/// root is available to distinguish deletion from an unavailable filesystem.
2816pub fn checkout_is_live(workspace_root: &Path) -> bool {
2817    let Some(parent) = workspace_root.parent() else {
2818        return true;
2819    };
2820    checkout_is_live_on(parent, workspace_root)
2821}
2822
2823/// Whether two existing paths sit on the same filesystem, so a rename can
2824/// carry a directory from one to the other.
2825///
2826/// A path that cannot be inspected answers `false`: a move that cannot be
2827/// checked is not one to attempt.
2828pub fn same_filesystem_paths(a: &Path, b: &Path) -> bool {
2829    let (Ok(a_metadata), Ok(b_metadata)) = (std::fs::metadata(a), std::fs::metadata(b)) else {
2830        return false;
2831    };
2832    same_filesystem(a, &a_metadata, b, &b_metadata)
2833}
2834
2835#[cfg(unix)]
2836fn same_filesystem(
2837    _a_path: &Path,
2838    a: &std::fs::Metadata,
2839    _b_path: &Path,
2840    b: &std::fs::Metadata,
2841) -> bool {
2842    use std::os::unix::fs::MetadataExt as _;
2843    a.dev() == b.dev()
2844}
2845
2846#[cfg(windows)]
2847fn same_filesystem(
2848    a_path: &Path,
2849    _a: &std::fs::Metadata,
2850    b_path: &Path,
2851    _b: &std::fs::Metadata,
2852) -> bool {
2853    windows_volume_name(a_path).is_some_and(|volume| windows_volume_name(b_path) == Some(volume))
2854}
2855
2856#[cfg(windows)]
2857fn windows_volume_name(path: &Path) -> Option<Vec<u16>> {
2858    use std::os::windows::ffi::OsStrExt as _;
2859    use windows_sys::Win32::Storage::FileSystem::{
2860        GetVolumeNameForVolumeMountPointW, GetVolumePathNameW,
2861    };
2862
2863    let path = path
2864        .as_os_str()
2865        .encode_wide()
2866        .chain(Some(0))
2867        .collect::<Vec<_>>();
2868    // Both APIs document MAX_PATH-sized output buffers for volume paths and
2869    // names. Failure is uncertainty, which deliberately keeps the checkout
2870    // live.
2871    let mut mount = vec![0_u16; 261];
2872    // SAFETY: `path` is nul-terminated and `mount` is writable for the stated
2873    // number of UTF-16 code units.
2874    if unsafe { GetVolumePathNameW(path.as_ptr(), mount.as_mut_ptr(), mount.len() as u32) } == 0 {
2875        return None;
2876    }
2877    let mut volume = vec![0_u16; 261];
2878    // SAFETY: the successful call above left `mount` nul-terminated, and
2879    // `volume` is writable for the stated number of UTF-16 code units.
2880    if unsafe {
2881        GetVolumeNameForVolumeMountPointW(mount.as_ptr(), volume.as_mut_ptr(), volume.len() as u32)
2882    } == 0
2883    {
2884        return None;
2885    }
2886    volume.truncate(volume.iter().position(|unit| *unit == 0)?);
2887    Some(volume)
2888}
2889
2890#[cfg(not(any(unix, windows)))]
2891fn same_filesystem(
2892    _a_path: &Path,
2893    _a: &std::fs::Metadata,
2894    _b_path: &Path,
2895    _b: &std::fs::Metadata,
2896) -> bool {
2897    false
2898}
2899
2900/// CAS paths reachable from the builds that live checkouts still depend on.
2901///
2902/// Recursing into output trees is the point: the descriptor blobs are tiny and
2903/// the leaf artifacts are where the bytes are, so rooting that stopped at the
2904/// top level would protect nothing worth protecting. Every read here is
2905/// tolerant -- a blob that has already been evicted, or that no longer parses,
2906/// simply roots less, and rooting less can only mean collecting sooner.
2907fn rooted_objects(store: &Path, identities: &BTreeSet<String>) -> Result<HashSet<PathBuf>> {
2908    let mut actions = BTreeSet::new();
2909    for identity in identities {
2910        // One manifest this build cannot read is not worth abandoning a sweep
2911        // over. It roots nothing, which can only mean collecting sooner.
2912        let manifest_actions = match task_manifest_actions(store, identity) {
2913            Ok(actions) => actions,
2914            Err(error) => {
2915                log::debug!("could not read the manifest for {identity}: {error}");
2916                continue;
2917            }
2918        };
2919        actions.extend(manifest_actions);
2920    }
2921    Ok(rooted_action_objects(store, actions))
2922}
2923
2924/// Return every local CAS path needed to restore the supplied actions.
2925fn rooted_action_objects(
2926    store: &Path,
2927    actions: impl IntoIterator<Item = CacheDigest>,
2928) -> HashSet<PathBuf> {
2929    let cas = LocalCas::new(store);
2930    let mut rooted = HashSet::new();
2931    let mut visited = BTreeSet::new();
2932    for action in actions {
2933        let Some(result) = read_action_result(store, &action) else {
2934            continue;
2935        };
2936        root_digest(&cas, &mut rooted, &result.action);
2937        if let Some(metadata) = &result.metadata {
2938            root_digest(&cas, &mut rooted, metadata);
2939            // The metadata blob is a descriptor too: it names the captured
2940            // stdout and stderr, and a restore reads both. Stopping at the
2941            // descriptor would leave the diagnostics of every action
2942            // unrooted -- including the one empty blob that every silent
2943            // compilation shares, which is enough on its own to turn a
2944            // rooted hit back into a miss.
2945            if let Some(rustc) = read_rustc_metadata(&cas, metadata) {
2946                root_digest(&cas, &mut rooted, &rustc.stdout);
2947                root_digest(&cas, &mut rooted, &rustc.stderr);
2948            }
2949        }
2950        if let Some(output_root) = &result.output_root {
2951            root_digest(&cas, &mut rooted, output_root);
2952            root_tree(&cas, output_root, &mut rooted, &mut visited);
2953        }
2954    }
2955    rooted
2956}
2957
2958fn read_action_result(store: &Path, action: &CacheDigest) -> Option<RemoteActionResult> {
2959    let path = mbx_cache_core::LocalActionCache::new(store)
2960        .path_for(action)
2961        .ok()?;
2962    let bytes = read_result_untouched(&path).ok()?;
2963    serde_json::from_slice(&bytes).ok()
2964}
2965
2966fn root_tree(
2967    cas: &LocalCas,
2968    output_root: &CacheDigest,
2969    rooted: &mut HashSet<PathBuf>,
2970    visited: &mut BTreeSet<CacheDigest>,
2971) {
2972    let mut pending = vec![output_root.clone()];
2973    while let Some(digest) = pending.pop() {
2974        if !visited.insert(digest.clone()) {
2975            continue;
2976        }
2977        let Some(directory) = read_directory(cas, &digest) else {
2978            continue;
2979        };
2980        for file in &directory.files {
2981            root_digest(cas, rooted, &file.digest);
2982        }
2983        for child in &directory.directories {
2984            root_digest(cas, rooted, &child.digest);
2985            pending.push(child.digest.clone());
2986        }
2987    }
2988}
2989
2990fn read_rustc_metadata(cas: &LocalCas, digest: &CacheDigest) -> Option<RustcMetadata> {
2991    let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2992    serde_json::from_slice(&bytes).ok()
2993}
2994
2995fn read_directory(cas: &LocalCas, digest: &CacheDigest) -> Option<CacheDirectory> {
2996    let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2997    serde_json::from_slice(&bytes).ok()
2998}
2999
3000fn root_digest(cas: &LocalCas, rooted: &mut HashSet<PathBuf>, digest: &CacheDigest) {
3001    if let Ok(path) = cas.path_for(digest) {
3002        rooted.insert(path);
3003    }
3004}
3005
3006#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3007enum Removal {
3008    Removed,
3009    Missing,
3010    Blocked,
3011}
3012
3013fn remove(path: &Path) -> Result<Removal> {
3014    match std::fs::remove_file(path) {
3015        Ok(()) => Ok(Removal::Removed),
3016        // A concurrent build may have evicted or replaced it already.
3017        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Removal::Missing),
3018        // Windows refuses to unlink a file another process holds open, which is
3019        // what a concurrent build reading this blob looks like. Skipping it
3020        // leaves the store over budget until the next sweep; failing would
3021        // abandon the sweep and leave it over budget for longer.
3022        Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
3023            log::debug!("could not evict {}: {error}", path.display());
3024            Ok(Removal::Blocked)
3025        }
3026        Err(error) => Err(error).wrap_err_with(|| format!("failed to evict {}", path.display())),
3027    }
3028}
3029
3030#[derive(Debug, Default, PartialEq, Eq)]
3031struct ObjectEvictions {
3032    removed_objects: u64,
3033    removed_bytes: u64,
3034    remaining_bytes: u64,
3035}
3036
3037/// Evict sorted objects without crossing the checkout-protection boundary
3038/// when an unrooted object cannot be removed.
3039fn evict_objects(
3040    objects: &[Entry],
3041    rooted: &HashSet<PathBuf>,
3042    mut live_bytes: u64,
3043    max_bytes: u64,
3044    mut remove_file: impl FnMut(&Path) -> Result<Removal>,
3045) -> Result<ObjectEvictions> {
3046    let mut outcome = ObjectEvictions::default();
3047    let mut blocked_unrooted = false;
3048    for entry in objects {
3049        if live_bytes <= max_bytes {
3050            break;
3051        }
3052        let is_rooted = rooted.contains(&entry.path);
3053        if is_rooted && blocked_unrooted {
3054            // A locked unrooted blob is temporarily part of the irreducible
3055            // store. Deleting a live checkout's artifacts in its place would
3056            // invert the protection ordering this collector promises.
3057            break;
3058        }
3059        match remove_file(&entry.path)? {
3060            Removal::Removed => {
3061                live_bytes = live_bytes.saturating_sub(entry.size);
3062                outcome.removed_objects += 1;
3063                outcome.removed_bytes += entry.size;
3064            }
3065            Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
3066            Removal::Blocked if !is_rooted => blocked_unrooted = true,
3067            Removal::Blocked => {}
3068        }
3069    }
3070    outcome.remaining_bytes = live_bytes;
3071    Ok(outcome)
3072}
3073
3074/// Whether an action result references an object the store no longer has.
3075///
3076/// The digests checked here are exactly the ones `LocalActionCache::store`
3077/// requires. If the sweep checked fewer, eviction could leave an entry that
3078/// cannot be republished -- `store` would reject the identical result for a
3079/// missing blob while the index still claimed to hold it.
3080///
3081/// Presence is all that is checked, not content. Verifying would re-hash a
3082/// large fraction of the store on every sweep, which a chore could afford and
3083/// an automatic sweep cannot; it would also refresh the access time of every
3084/// descriptor blob while leaving the artifacts alone, biasing the very
3085/// ordering this module depends on. A blob that is present but corrupt now
3086/// keeps its result and turns into a miss on restore, and both CAS write paths
3087/// republish over a corrupt blob rather than trusting it.
3088///
3089/// With `walk_tree`, every directory and file the output tree lists is checked
3090/// too. Eviction removes individual objects, so it can take a file out of a tree
3091/// whose own object survives; the result would then stay listed and fail its
3092/// next restore with a missing blob until a rebuild replaced it. Without it
3093/// only the top-level objects are checked, which keeps a sweep that evicted
3094/// nothing to a directory walk and one read per result.
3095fn action_result_is_dangling(
3096    cas: &LocalCas,
3097    path: &Path,
3098    virtually_removed: &HashSet<PathBuf>,
3099    walk_tree: bool,
3100    trees: &mut HashMap<CacheDigest, bool>,
3101) -> Result<bool> {
3102    let bytes = match read_result_untouched(path) {
3103        Ok(bytes) => bytes,
3104        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
3105        Err(error) => {
3106            return Err(error).wrap_err_with(|| format!("failed to read {}", path.display()));
3107        }
3108    };
3109    action_result_bytes_are_dangling(cas, &bytes, virtually_removed, walk_tree, trees)
3110}
3111
3112/// Whether an I/O error means another process holds the file.
3113///
3114/// Windows reports a read of a range another process has locked as
3115/// ERROR_LOCK_VIOLATION (33), and an open the other handle's sharing mode
3116/// refuses as ERROR_SHARING_VIOLATION (32); neither maps to a portable error
3117/// kind. Elsewhere this is a permission error.
3118fn held_by_another_process(error: &std::io::Error) -> bool {
3119    error.kind() == std::io::ErrorKind::PermissionDenied
3120        || (cfg!(windows) && matches!(error.raw_os_error(), Some(32 | 33)))
3121}
3122
3123fn action_result_bytes_are_dangling(
3124    cas: &LocalCas,
3125    bytes: &[u8],
3126    virtually_removed: &HashSet<PathBuf>,
3127    walk_tree: bool,
3128    trees: &mut HashMap<CacheDigest, bool>,
3129) -> Result<bool> {
3130    // An unparseable result is already useless; the cache rejects it on read.
3131    let Ok(result) = serde_json::from_slice::<RemoteActionResult>(bytes) else {
3132        return Ok(false);
3133    };
3134    let missing = |digest: &CacheDigest| match cas.path_for(digest) {
3135        // A path that cannot be inspected is not known to be gone.
3136        Ok(path) => virtually_removed.contains(&path) || matches!(path.try_exists(), Ok(false)),
3137        // A digest the CAS cannot even address is not one it can hold.
3138        Err(_) => true,
3139    };
3140    for digest in [
3141        Some(&result.action),
3142        result.metadata.as_ref(),
3143        result.output_root.as_ref(),
3144    ]
3145    .into_iter()
3146    .flatten()
3147    {
3148        if missing(digest) {
3149            return Ok(true);
3150        }
3151    }
3152    if walk_tree && let Some(output_root) = &result.output_root {
3153        return Ok(!tree_is_complete(cas, output_root, &missing, trees));
3154    }
3155    Ok(false)
3156}
3157
3158/// Whether every file and directory under `digest` is present, remembering the
3159/// verdict in `trees` so a subtree shared by many results is read once per sweep.
3160fn tree_is_complete(
3161    cas: &LocalCas,
3162    digest: &CacheDigest,
3163    missing: &impl Fn(&CacheDigest) -> bool,
3164    trees: &mut HashMap<CacheDigest, bool>,
3165) -> bool {
3166    if let Some(&complete) = trees.get(digest) {
3167        return complete;
3168    }
3169    // Content addressing rules out cycles; this only keeps a malformed tree
3170    // from recursing forever.
3171    trees.insert(digest.clone(), true);
3172    // Present but unreadable is left to the restore, as for any blob.
3173    let complete = read_directory(cas, digest).is_none_or(|directory| {
3174        directory.files.iter().all(|file| !missing(&file.digest))
3175            && directory.directories.iter().all(|child| {
3176                !missing(&child.digest) && tree_is_complete(cas, &child.digest, missing, trees)
3177            })
3178    });
3179    trees.insert(digest.clone(), complete);
3180    complete
3181}
3182
3183struct Entry {
3184    path: PathBuf,
3185    size: u64,
3186    used: SystemTime,
3187}
3188
3189fn walk_files(root: &Path) -> Result<Vec<Entry>> {
3190    let mut entries = Vec::new();
3191    let mut pending = vec![root.to_path_buf()];
3192    while let Some(directory) = pending.pop() {
3193        let listing = match std::fs::read_dir(&directory) {
3194            Ok(listing) => listing,
3195            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
3196            Err(error) => {
3197                return Err(error)
3198                    .wrap_err_with(|| format!("failed to read {}", directory.display()));
3199            }
3200        };
3201        for entry in listing {
3202            let entry = entry?;
3203            let metadata = match entry.metadata() {
3204                Ok(metadata) => metadata,
3205                // A concurrent build may be publishing into the store.
3206                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
3207                Err(error) => return Err(error.into()),
3208            };
3209            if metadata.is_dir() {
3210                pending.push(entry.path());
3211            } else if metadata.is_file() {
3212                let used = metadata
3213                    .accessed()
3214                    .or_else(|_| metadata.modified())
3215                    .unwrap_or(SystemTime::UNIX_EPOCH);
3216                entries.push(Entry {
3217                    path: entry.path(),
3218                    size: metadata.len(),
3219                    used,
3220                });
3221            }
3222        }
3223    }
3224    Ok(entries)
3225}
3226
3227fn read_dir_or_empty(root: &Path) -> Result<Vec<std::fs::DirEntry>> {
3228    match std::fs::read_dir(root) {
3229        Ok(entries) => entries
3230            .collect::<std::io::Result<Vec<_>>>()
3231            .map_err(Into::into),
3232        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
3233        Err(error) => Err(error).wrap_err_with(|| format!("failed to read {}", root.display())),
3234    }
3235}
3236
3237fn digest_from_path(path: &Path, action_result: bool) -> Option<CacheDigest> {
3238    let name = path.file_name()?.to_str()?;
3239    let name = if action_result {
3240        name.strip_suffix(".json")?
3241    } else {
3242        name
3243    };
3244    let (hash, size) = name.rsplit_once('-')?;
3245    let algorithm = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
3246    let digest = CacheDigest {
3247        algorithm,
3248        hash: hash.to_string(),
3249        size: size.parse().ok()?,
3250    };
3251    digest.validate().ok()?;
3252    Some(digest)
3253}
3254
3255fn addressed_digest(store: &Path, path: &Path, action_result: bool) -> Option<CacheDigest> {
3256    let digest = digest_from_path(path, action_result)?;
3257    let canonical = if action_result {
3258        mbx_cache_core::LocalActionCache::new(store)
3259            .path_for(&digest)
3260            .ok()?
3261    } else {
3262        LocalCas::new(store).path_for(&digest).ok()?
3263    };
3264    (canonical == path).then_some(digest)
3265}
3266
3267fn cached_tree_bytes(cache: &mut BTreeMap<PathBuf, u64>, root: &Path) -> u64 {
3268    *cache
3269        .entry(root.to_path_buf())
3270        .or_insert_with(|| tree_bytes(root))
3271}
3272
3273fn tree_bytes(root: &Path) -> u64 {
3274    let Ok(root_metadata) = std::fs::metadata(root) else {
3275        return 0;
3276    };
3277    if root_metadata.is_file() {
3278        return root_metadata.len();
3279    }
3280    let Ok(entries) = std::fs::read_dir(root) else {
3281        return 0;
3282    };
3283    let mut bytes = 0_u64;
3284    let mut pending = entries
3285        .filter_map(std::result::Result::ok)
3286        .map(|entry| entry.path())
3287        .collect::<Vec<_>>();
3288    while let Some(path) = pending.pop() {
3289        let Ok(metadata) = std::fs::symlink_metadata(&path) else {
3290            continue;
3291        };
3292        if metadata.is_file() {
3293            bytes = bytes.saturating_add(metadata.len());
3294        } else if metadata.is_dir()
3295            && let Ok(entries) = std::fs::read_dir(path)
3296        {
3297            pending.extend(
3298                entries
3299                    .filter_map(std::result::Result::ok)
3300                    .map(|entry| entry.path()),
3301            );
3302        }
3303    }
3304    bytes
3305}
3306
3307#[cfg(test)]
3308#[path = "store_tests.rs"]
3309mod tests;
3310
3311/// [`write_atomic`] without the sync: for records every build rewrites and
3312/// that a torn or missing file only makes one build older.
3313fn write_advisory(path: &Path, contents: &[u8]) -> Result<()> {
3314    let parent = path
3315        .parent()
3316        .filter(|parent| !parent.as_os_str().is_empty())
3317        .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
3318    std::fs::create_dir_all(parent)
3319        .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
3320    let mut temporary = tempfile::Builder::new()
3321        .prefix(".mbx-")
3322        .tempfile_in(parent)?;
3323    use std::io::Write as _;
3324    temporary.write_all(contents)?;
3325    temporary
3326        .persist(path)
3327        .map_err(|error| error.error)
3328        .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
3329    Ok(())
3330}
3331
3332fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
3333    let parent = path
3334        .parent()
3335        .filter(|parent| !parent.as_os_str().is_empty())
3336        .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
3337    std::fs::create_dir_all(parent)
3338        .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
3339    let mut temporary = tempfile::Builder::new()
3340        .prefix(".mbx-")
3341        .tempfile_in(parent)?;
3342    use std::io::Write as _;
3343    temporary.write_all(contents)?;
3344    temporary.as_file_mut().sync_all()?;
3345    temporary
3346        .persist(path)
3347        .map_err(|error| error.error)
3348        .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
3349    Ok(())
3350}