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