Skip to main content

ci_engine/
cache.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Worktree-relative cache directories persisted under `cache_root`.
3
4use std::{
5    collections::{BTreeMap, BTreeSet},
6    path::{Path, PathBuf},
7};
8
9use ci_config::Check;
10use thiserror::Error;
11
12/// Prefix for every cache-directory environment variable.
13pub const CACHE_ENV_PREFIX: &str = "HCI_CACHE_";
14
15/// A cache path the host-exec slice will not persist.
16#[derive(Debug, Error)]
17pub enum CachePathError {
18    /// Absolute path or `..` would escape the evaluated worktree.
19    #[error(
20        "check {check:?} cache path {path:?} is not a worktree-relative directory (absolute paths and .. are refused)"
21    )]
22    EscapesWorktree {
23        /// Offending check.
24        check: String,
25        /// Declared path.
26        path: String,
27    },
28    /// The only durable copy could not be written to the slot.
29    #[error("check {check:?} cache path {path:?} could not be saved: {reason}")]
30    SaveFailed {
31        /// Offending check.
32        check: String,
33        /// Declared path.
34        path: String,
35        /// Filesystem error.
36        reason: String,
37    },
38}
39
40/// Prepared cache slots for one check.
41#[derive(Debug, Clone, Default)]
42pub struct PreparedCaches {
43    /// Environment exports. Values are the worktree directories the check uses.
44    pub env: BTreeMap<String, String>,
45    /// Worktree directories bound for this check.
46    pub dirs: Vec<PathBuf>,
47    slots: Vec<BoundSlot>,
48}
49
50#[derive(Debug, Clone)]
51struct BoundSlot {
52    check: String,
53    path: String,
54    worktree: PathBuf,
55    slot: PathBuf,
56}
57
58/// Bind declared cache paths onto `workdir/<path>` and hydrate from `cache_root`.
59///
60/// Slots are keyed by the declared worktree-relative path so every check in
61/// the pipeline that names `target` shares one slot. A failed hydrate
62/// degrades to a cold worktree directory. Invalid paths fail closed.
63///
64/// Hydrate only when the worktree path is missing or empty. A later check
65/// in the same run must not have an empty slot copied over files a previous
66/// check just wrote.
67pub fn prepare_caches(
68    check_name: &str,
69    paths: &[String],
70    workdir: &Path,
71    cache_root: &Path,
72) -> Result<PreparedCaches, CachePathError> {
73    let mut prepared = PreparedCaches::default();
74    for path in paths {
75        if !ci_config::cache_path_is_worktree_relative(path) {
76            return Err(CachePathError::EscapesWorktree {
77                check: check_name.to_string(),
78                path: path.clone(),
79            });
80        }
81        let worktree = workdir.join(path);
82        let slot = cache_root.join(path);
83        hydrate_or_cold(&slot, &worktree);
84        prepared.env.insert(
85            format!("{CACHE_ENV_PREFIX}{}", slot_name(path)),
86            worktree.display().to_string(),
87        );
88        prepared.dirs.push(worktree.clone());
89        prepared.slots.push(BoundSlot {
90            check: check_name.to_string(),
91            path: path.clone(),
92            worktree,
93            slot,
94        });
95    }
96    Ok(prepared)
97}
98
99/// Copy each worktree cache directory back to the shared slot.
100///
101/// Called after the check, success or failure. A missing worktree path is a
102/// no-op. A failed save is an error so the only copy is not dropped silently.
103/// The worktree directory stays hot for later checks in this run.
104pub fn save_caches(prepared: &PreparedCaches) -> Result<(), CachePathError> {
105    for bound in &prepared.slots {
106        if bound.worktree.exists() {
107            replace_dir(&bound.worktree, &bound.slot).map_err(|error| {
108                CachePathError::SaveFailed {
109                    check: bound.check.clone(),
110                    path: bound.path.clone(),
111                    reason: error.to_string(),
112                }
113            })?;
114        }
115    }
116    Ok(())
117}
118
119/// Remove worktree cache directories after the whole pipeline.
120///
121/// The durable copy is already in the slot. This keeps the evaluated tree
122/// clean for `ensure_unchanged`.
123pub fn restore_worktree_cache_dirs(workdir: &Path, checks: &[Check]) {
124    let mut seen = BTreeSet::new();
125    for check in checks {
126        for path in &check.cache_paths {
127            if !ci_config::cache_path_is_worktree_relative(path) {
128                continue;
129            }
130            if !seen.insert(path.as_str()) {
131                continue;
132            }
133            let directory = workdir.join(path);
134            if directory.exists() {
135                let _ = std::fs::remove_dir_all(&directory);
136            }
137        }
138    }
139}
140
141fn hydrate_or_cold(slot: &Path, worktree: &Path) {
142    if !worktree_is_missing_or_empty(worktree) {
143        return;
144    }
145    if slot_has_entries(slot) {
146        if copy_tree(slot, worktree).is_err() {
147            let _ = std::fs::remove_dir_all(worktree);
148            let _ = std::fs::create_dir_all(worktree);
149        }
150        return;
151    }
152    let _ = std::fs::create_dir_all(worktree);
153}
154
155fn worktree_is_missing_or_empty(worktree: &Path) -> bool {
156    match std::fs::symlink_metadata(worktree) {
157        Err(_) => true,
158        Ok(meta) if meta.is_dir() => dir_is_empty(worktree),
159        Ok(_) => false,
160    }
161}
162
163fn slot_has_entries(slot: &Path) -> bool {
164    match std::fs::symlink_metadata(slot) {
165        Err(_) => false,
166        Ok(meta) if meta.is_dir() => !dir_is_empty(slot),
167        Ok(_) => true,
168    }
169}
170
171fn dir_is_empty(path: &Path) -> bool {
172    std::fs::read_dir(path)
173        .ok()
174        .is_none_or(|mut entries| entries.next().is_none())
175}
176
177fn replace_dir(src: &Path, slot: &Path) -> std::io::Result<()> {
178    let Some(parent) = slot.parent() else {
179        return Err(std::io::Error::new(
180            std::io::ErrorKind::InvalidInput,
181            "cache slot is missing a parent directory",
182        ));
183    };
184    let Some(name) = slot.file_name() else {
185        return Err(std::io::Error::new(
186            std::io::ErrorKind::InvalidInput,
187            "cache slot is missing a file name",
188        ));
189    };
190    std::fs::create_dir_all(parent)?;
191    let staging = parent.join(format!(".{}.staging", name.to_string_lossy()));
192    if staging.exists() {
193        std::fs::remove_dir_all(&staging)?;
194    }
195    copy_tree(src, &staging)?;
196    if slot.exists() {
197        std::fs::remove_dir_all(slot)?;
198    }
199    std::fs::rename(&staging, slot)
200}
201
202fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
203    std::fs::create_dir_all(dst)?;
204    for entry in std::fs::read_dir(src)? {
205        let entry = entry?;
206        let from = entry.path();
207        let to = dst.join(entry.file_name());
208        let file_type = entry.file_type()?;
209        if file_type.is_symlink() {
210            copy_symlink(&from, &to)?;
211        } else if file_type.is_dir() {
212            copy_tree(&from, &to)?;
213        } else if file_type.is_file() {
214            std::fs::copy(&from, &to)?;
215        }
216    }
217    Ok(())
218}
219
220fn copy_symlink(from: &Path, to: &Path) -> std::io::Result<()> {
221    let target = std::fs::read_link(from)?;
222    if let Ok(meta) = std::fs::symlink_metadata(to) {
223        if meta.is_dir() && !meta.file_type().is_symlink() {
224            std::fs::remove_dir_all(to)?;
225        } else {
226            std::fs::remove_file(to)?;
227        }
228    }
229    #[cfg(unix)]
230    {
231        std::os::unix::fs::symlink(target, to)
232    }
233    #[cfg(not(unix))]
234    {
235        let _ = target;
236        Err(std::io::Error::new(
237            std::io::ErrorKind::Unsupported,
238            "copying cache symlinks requires a unix host-exec",
239        ))
240    }
241}
242
243fn slot_name(path: &str) -> String {
244    let mut output = String::with_capacity(path.len());
245    let mut separated = true;
246    for character in path.chars() {
247        if character.is_ascii_alphanumeric() {
248            output.push(character.to_ascii_uppercase());
249            separated = false;
250        } else if !separated {
251            output.push('_');
252            separated = true;
253        }
254    }
255    while output.ends_with('_') {
256        output.pop();
257    }
258    if output.is_empty() {
259        "CACHE".to_string()
260    } else {
261        output
262    }
263}