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