Skip to main content

supercov_engine/
build_cache.rs

1//! Exact-fingerprint reuse of instrumented JavaScript build outputs.
2
3use std::{
4    collections::BTreeMap,
5    fs,
6    path::{Component, Path, PathBuf},
7    process::Command,
8};
9
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12
13use crate::{lifecycle::atomic_write, project_discovery::CoverageProject, run_store::RunIntegrity};
14
15pub const BUILD_CACHE_SCHEMA_VERSION: u32 = 1;
16const OUTPUT_CANDIDATES: &[&str] = &["build", "dist", ".next", ".nuxt", ".output"];
17const SCAN_EXCLUSIONS: &[&str] = &[".git", ".supercov", "node_modules"];
18const SCAN_DEPTH_LIMIT: usize = 6;
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase", deny_unknown_fields)]
22pub struct BuildCacheMetadata {
23    pub schema_version: u32,
24    pub key: String,
25    pub created_at: String,
26    pub artifact_paths: Vec<String>,
27}
28
29#[derive(Serialize)]
30#[serde(rename_all = "camelCase")]
31struct CacheIdentity<'a> {
32    schema_version: u32,
33    execution_fingerprint: &'a str,
34    /// Supercov's own build. `execution` no longer carries it, and cached
35    /// output from a different instrumenter must never be reused.
36    instrumenter_fingerprint: &'a str,
37    adapter: crate::project_discovery::BuildAdapter,
38    /// Instrumented sources import the runtime by absolute path, so output
39    /// built in one place does not run from another.
40    root: &'a Path,
41    /// Which files are instrumented. The execution fingerprint digests every
42    /// first-party file whatever its classification, so naming source roots
43    /// on a project that had already run kept the output built without them.
44    sources: &'a [String],
45    command: &'a [String],
46    environment: &'a BTreeMap<String, String>,
47    node: String,
48    platform: &'static str,
49    architecture: &'static str,
50}
51
52fn safe_relative(path: &Path) -> bool {
53    path.components().next().is_some()
54        && path
55            .components()
56            .all(|component| matches!(component, Component::Normal(_)))
57}
58
59fn regular_artifact(workspace: &Path, relative: &Path) -> bool {
60    safe_relative(relative)
61        && fs::symlink_metadata(workspace.join(relative))
62            .is_ok_and(|metadata| metadata.file_type().is_file() || metadata.file_type().is_dir())
63}
64
65fn node_version() -> String {
66    std::env::var("SUPERCOV_NODE_VERSION").unwrap_or_else(|_| {
67        Command::new("node")
68            .args(["--print", "process.versions.node"])
69            .output()
70            .ok()
71            .filter(|output| output.status.success())
72            .and_then(|output| String::from_utf8(output.stdout).ok())
73            .map(|value| value.trim().to_owned())
74            .filter(|value| !value.is_empty())
75            .unwrap_or_else(|| "unavailable".into())
76    })
77}
78
79pub fn build_cache_key(
80    integrity: &RunIntegrity,
81    project: &CoverageProject,
82) -> Result<String, String> {
83    let identity = CacheIdentity {
84        schema_version: BUILD_CACHE_SCHEMA_VERSION,
85        execution_fingerprint: &integrity.fingerprint.execution,
86        instrumenter_fingerprint: &integrity.fingerprint.instrumenter,
87        adapter: project.build_adapter,
88        root: &project.root,
89        sources: &project.source_files,
90        command: &project.build_command,
91        environment: &project.build_environment,
92        node: node_version(),
93        platform: std::env::consts::OS,
94        architecture: std::env::consts::ARCH,
95    };
96    let bytes = serde_json::to_vec(&identity)
97        .map_err(|error| format!("failed to serialize build-cache identity: {error}"))?;
98    Ok(format!("{:x}", Sha256::digest(bytes)))
99}
100
101pub fn read_build_cache(workspace: &Path, key: &str) -> Option<BuildCacheMetadata> {
102    let path = workspace.join(".supercov/build-cache.json");
103    if !fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.file_type().is_file()) {
104        return None;
105    }
106    let metadata: BuildCacheMetadata = serde_json::from_slice(&fs::read(path).ok()?).ok()?;
107    if metadata.schema_version != BUILD_CACHE_SCHEMA_VERSION
108        || metadata.key != key
109        || metadata.artifact_paths.is_empty()
110        || metadata
111            .artifact_paths
112            .iter()
113            .any(|path| !regular_artifact(workspace, Path::new(path)))
114    {
115        return None;
116    }
117    Some(metadata)
118}
119
120pub fn reuse_paths(metadata: &BuildCacheMetadata) -> Vec<PathBuf> {
121    metadata
122        .artifact_paths
123        .iter()
124        .map(PathBuf::from)
125        .chain([PathBuf::from(".supercov/build-cache.json")])
126        .collect()
127}
128
129#[derive(Deserialize, Default)]
130struct DeclaredOutputs {
131    #[serde(default)]
132    paths: Vec<String>,
133}
134
135/// Monorepo build outputs live at package roots (`packages/*/dist`), not the
136/// workspace root, so candidates come from a depth-limited scan of the whole
137/// mirror rather than a root-only check.
138fn workspace_output_directories(workspace: &Path) -> Vec<String> {
139    let mut found = Vec::new();
140    let mut pending = vec![(workspace.to_owned(), 0usize)];
141    while let Some((directory, depth)) = pending.pop() {
142        let Ok(entries) = fs::read_dir(&directory) else {
143            continue;
144        };
145        for entry in entries.flatten() {
146            if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
147                continue;
148            }
149            let name = entry.file_name();
150            let Some(name) = name.to_str() else {
151                continue;
152            };
153            if SCAN_EXCLUSIONS.contains(&name) {
154                continue;
155            }
156            if OUTPUT_CANDIDATES.contains(&name) {
157                if let Ok(relative) = entry.path().strip_prefix(workspace) {
158                    found.push(slash_path(relative));
159                }
160            } else if depth < SCAN_DEPTH_LIMIT {
161                pending.push((entry.path(), depth + 1));
162            }
163        }
164    }
165    found
166}
167
168/// Cache metadata is read back through `Path::new`, which accepts `/` on
169/// every host; a path spelled with `\\` would only ever be right on the host
170/// that wrote it.
171fn slash_path(path: &Path) -> String {
172    path.components()
173        .map(|component| component.as_os_str().to_string_lossy().into_owned())
174        .collect::<Vec<_>>()
175        .join("/")
176}
177
178pub fn write_build_cache(
179    project_root: &Path,
180    workspace: &Path,
181    key: &str,
182    created_at: &str,
183) -> Result<Option<BuildCacheMetadata>, String> {
184    let declared = fs::read(workspace.join(".supercov/build-outputs.json"))
185        .ok()
186        .and_then(|bytes| serde_json::from_slice::<DeclaredOutputs>(&bytes).ok())
187        .unwrap_or_default();
188    let mut candidates = workspace_output_directories(workspace)
189        .into_iter()
190        .chain(
191            declared
192                .paths
193                .into_iter()
194                .filter(|path| safe_relative(Path::new(path))),
195        )
196        .collect::<Vec<_>>();
197    candidates.sort();
198    candidates.dedup();
199    candidates.retain(|path| regular_artifact(workspace, Path::new(path)));
200    let existing = candidates.clone();
201    candidates.retain(|path| {
202        !existing.iter().any(|parent| {
203            parent != path
204                && Path::new(path)
205                    .strip_prefix(Path::new(parent))
206                    .is_ok_and(|local| local.components().next().is_some())
207        })
208    });
209    if candidates.is_empty() || !regular_artifact(workspace, Path::new(".supercov/manifest.json")) {
210        return Ok(None);
211    }
212    candidates.push(".supercov/manifest.json".into());
213    let metadata = BuildCacheMetadata {
214        schema_version: BUILD_CACHE_SCHEMA_VERSION,
215        key: key.into(),
216        created_at: created_at.into(),
217        artifact_paths: candidates,
218    };
219    let mut bytes = serde_json::to_vec_pretty(&metadata)
220        .map_err(|error| format!("failed to serialize build-cache metadata: {error}"))?;
221    bytes.push(b'\n');
222    atomic_write(
223        project_root,
224        &workspace.join(".supercov/build-cache.json"),
225        &bytes,
226    )
227    .map_err(|error| error.to_string())?;
228    Ok(Some(metadata))
229}
230
231#[cfg(test)]
232mod tests {
233    use std::time::{SystemTime, UNIX_EPOCH};
234
235    use super::*;
236
237    fn temporary() -> PathBuf {
238        // Two tests starting on the same nanosecond drew the same directory
239        // and polluted each other's artifact scans; the counter breaks ties.
240        static UNIQUE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
241        let nonce = SystemTime::now()
242            .duration_since(UNIX_EPOCH)
243            .unwrap()
244            .as_nanos();
245        let root = std::env::temp_dir().join(format!(
246            "supercov-build-cache-{}-{nonce}-{}",
247            std::process::id(),
248            UNIQUE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
249        ));
250        fs::create_dir_all(&root).unwrap();
251        root
252    }
253
254    #[test]
255    fn writes_reads_and_rejects_incomplete_exact_cache_metadata() {
256        let root = temporary();
257        let workspace = root.join(".supercov/cache/workspace/project");
258        fs::create_dir_all(workspace.join(".supercov")).unwrap();
259        fs::create_dir_all(workspace.join("dist")).unwrap();
260        fs::write(workspace.join("dist/app.js"), "built").unwrap();
261        fs::write(workspace.join(".supercov/manifest.json"), "{}").unwrap();
262        let written = write_build_cache(&root, &workspace, "key", "time")
263            .unwrap()
264            .unwrap();
265        assert_eq!(written.artifact_paths, ["dist", ".supercov/manifest.json"]);
266        assert_eq!(read_build_cache(&workspace, "key"), Some(written.clone()));
267        assert_eq!(
268            reuse_paths(&written),
269            [
270                PathBuf::from("dist"),
271                PathBuf::from(".supercov/manifest.json"),
272                PathBuf::from(".supercov/build-cache.json"),
273            ]
274        );
275        fs::remove_dir_all(workspace.join("dist")).unwrap();
276        assert_eq!(read_build_cache(&workspace, "key"), None);
277        fs::remove_dir_all(root).unwrap();
278    }
279
280    #[test]
281    fn records_package_level_outputs_and_skips_dependency_trees() {
282        let root = temporary();
283        let workspace = root.join(".supercov/cache/workspace/project");
284        fs::create_dir_all(workspace.join(".supercov")).unwrap();
285        fs::write(workspace.join(".supercov/manifest.json"), "{}").unwrap();
286        fs::create_dir_all(workspace.join("packages/app/dist/assets")).unwrap();
287        fs::write(workspace.join("packages/app/dist/app.js"), "built").unwrap();
288        fs::create_dir_all(workspace.join("packages/site/.next")).unwrap();
289        fs::create_dir_all(workspace.join("node_modules/library/dist")).unwrap();
290        fs::create_dir_all(workspace.join("packages/app/node_modules/local/dist")).unwrap();
291        let written = write_build_cache(&root, &workspace, "key", "time")
292            .unwrap()
293            .unwrap();
294        assert_eq!(
295            written.artifact_paths,
296            [
297                "packages/app/dist",
298                "packages/site/.next",
299                ".supercov/manifest.json"
300            ]
301        );
302        assert_eq!(read_build_cache(&workspace, "key"), Some(written));
303        fs::remove_dir_all(root).unwrap();
304    }
305}