Skip to main content

supercov_engine/
rust_build_cache.rs

1//! Exact-input stable workspace metadata for the owned Rust frontend.
2
3use std::{
4    fs,
5    path::{Path, PathBuf},
6    process::Command,
7};
8
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use crate::{coverage_report::CoverageManifest, lifecycle::atomic_write, run_store::RunIntegrity};
13
14pub const RUST_BUILD_CACHE_SCHEMA_VERSION: u32 = 1;
15const CACHE_FILE: &str = ".supercov/rust-build-cache.json";
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase", deny_unknown_fields)]
19pub struct RustArtifactFingerprint {
20    pub path: String,
21    pub bytes: u64,
22    pub sha256: String,
23}
24
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase", deny_unknown_fields)]
27pub struct RustBuildCacheMetadata {
28    pub schema_version: u32,
29    pub key: String,
30    pub created_at: String,
31    pub source_files: Vec<String>,
32    pub instrumented_source_sha256: String,
33    pub artifacts: Vec<RustArtifactFingerprint>,
34    pub manifest: CoverageManifest,
35}
36
37#[derive(Serialize)]
38#[serde(rename_all = "camelCase")]
39struct RustBuildCacheIdentity<'a> {
40    schema_version: u32,
41    execution_fingerprint: &'a str,
42    /// Supercov's own build. `execution` no longer carries it, and cached
43    /// output from a different instrumenter must never be reused.
44    instrumenter_fingerprint: &'a str,
45    command: &'a [String],
46    rustc: String,
47    cargo: String,
48    platform: &'static str,
49    architecture: &'static str,
50}
51
52fn tool_version(program: &str, arguments: &[&str]) -> String {
53    Command::new(program)
54        .args(arguments)
55        .output()
56        .ok()
57        .filter(|output| output.status.success())
58        .and_then(|output| String::from_utf8(output.stdout).ok())
59        .map(|value| value.trim().to_owned())
60        .filter(|value| !value.is_empty())
61        .unwrap_or_else(|| "unavailable".into())
62}
63
64pub fn rust_build_cache_key(
65    integrity: &RunIntegrity,
66    command: &[String],
67) -> Result<String, serde_json::Error> {
68    let identity = RustBuildCacheIdentity {
69        schema_version: RUST_BUILD_CACHE_SCHEMA_VERSION,
70        execution_fingerprint: &integrity.fingerprint.execution,
71        instrumenter_fingerprint: &integrity.fingerprint.instrumenter,
72        command,
73        rustc: tool_version("rustc", &["-vV"]),
74        cargo: tool_version("cargo", &["-Vv"]),
75        platform: std::env::consts::OS,
76        architecture: std::env::consts::ARCH,
77    };
78    Ok(format!(
79        "{:x}",
80        Sha256::digest(serde_json::to_vec(&identity)?)
81    ))
82}
83
84fn regular_directory(path: &Path) -> bool {
85    fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
86}
87
88fn safe_relative(value: &str) -> bool {
89    let path = Path::new(value);
90    !value.is_empty()
91        && !path.is_absolute()
92        && path
93            .components()
94            .all(|component| matches!(component, std::path::Component::Normal(_)))
95}
96
97fn digest_files(root: &Path, files: &[String]) -> Option<String> {
98    let mut digest = Sha256::new();
99    for relative in files {
100        if !safe_relative(relative) {
101            return None;
102        }
103        let path = root.join(relative);
104        let metadata = fs::symlink_metadata(&path).ok()?;
105        if !metadata.file_type().is_file() {
106            return None;
107        }
108        let bytes = fs::read(path).ok()?;
109        digest.update((relative.len() as u64).to_le_bytes());
110        digest.update(relative.as_bytes());
111        digest.update((bytes.len() as u64).to_le_bytes());
112        digest.update(bytes);
113    }
114    Some(format!("{:x}", digest.finalize()))
115}
116
117fn fingerprint_artifacts(
118    target_directory: &Path,
119    artifacts: &[PathBuf],
120) -> Result<Vec<RustArtifactFingerprint>, String> {
121    let canonical_target = fs::canonicalize(target_directory).map_err(|error| error.to_string())?;
122    let mut fingerprints = Vec::new();
123    for artifact in artifacts {
124        let artifact = fs::canonicalize(artifact).map_err(|error| error.to_string())?;
125        let relative = artifact
126            .strip_prefix(&canonical_target)
127            .map_err(|_| {
128                format!(
129                    "cached Rust artifact escaped target: {}",
130                    artifact.display()
131                )
132            })?
133            .to_string_lossy()
134            .replace('\\', "/");
135        if !safe_relative(&relative) {
136            return Err(format!("unsafe cached Rust artifact: {relative}"));
137        }
138        let metadata = fs::symlink_metadata(&artifact).map_err(|error| error.to_string())?;
139        if !metadata.file_type().is_file() {
140            return Err(format!(
141                "cached Rust artifact is not a regular file: {}",
142                artifact.display()
143            ));
144        }
145        let bytes = fs::read(&artifact).map_err(|error| error.to_string())?;
146        fingerprints.push(RustArtifactFingerprint {
147            path: relative,
148            bytes: bytes.len() as u64,
149            sha256: format!("{:x}", Sha256::digest(bytes)),
150        });
151    }
152    fingerprints.sort_by(|left, right| left.path.cmp(&right.path));
153    fingerprints.dedup_by(|left, right| left.path == right.path);
154    Ok(fingerprints)
155}
156
157fn artifacts_match(target_directory: &Path, expected: &[RustArtifactFingerprint]) -> bool {
158    if expected.is_empty() || expected.windows(2).any(|pair| pair[0].path >= pair[1].path) {
159        return false;
160    }
161    expected.iter().all(|expected| {
162        if !safe_relative(&expected.path) {
163            return false;
164        }
165        let path = target_directory.join(&expected.path);
166        let Ok(metadata) = fs::symlink_metadata(&path) else {
167            return false;
168        };
169        if !metadata.file_type().is_file() || metadata.len() != expected.bytes {
170            return false;
171        }
172        fs::read(path).is_ok_and(|bytes| format!("{:x}", Sha256::digest(bytes)) == expected.sha256)
173    })
174}
175
176pub fn read_rust_build_cache(
177    workspace: &Path,
178    target_directory: &Path,
179    key: &str,
180) -> Option<RustBuildCacheMetadata> {
181    if !regular_directory(workspace) || !regular_directory(target_directory) {
182        return None;
183    }
184    let path = workspace.join(CACHE_FILE);
185    if !fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.file_type().is_file()) {
186        return None;
187    }
188    let metadata: RustBuildCacheMetadata = serde_json::from_slice(&fs::read(path).ok()?).ok()?;
189    if metadata.schema_version != RUST_BUILD_CACHE_SCHEMA_VERSION
190        || metadata.key != key
191        || metadata.source_files.is_empty()
192        || metadata.manifest.points.is_empty()
193        || metadata
194            .source_files
195            .windows(2)
196            .any(|pair| pair[0] >= pair[1])
197        || digest_files(workspace, &metadata.source_files).as_deref()
198            != Some(metadata.instrumented_source_sha256.as_str())
199        || !artifacts_match(target_directory, &metadata.artifacts)
200    {
201        return None;
202    }
203    Some(metadata)
204}
205
206pub fn write_rust_build_cache(
207    project_root: &Path,
208    workspace: &Path,
209    key: &str,
210    created_at: &str,
211    source_files: &[String],
212    manifest: &CoverageManifest,
213    artifacts: &[PathBuf],
214) -> Result<RustBuildCacheMetadata, String> {
215    let mut source_files = source_files.to_vec();
216    source_files.sort();
217    source_files.dedup();
218    let instrumented_source_sha256 = digest_files(workspace, &source_files)
219        .ok_or_else(|| "could not authenticate instrumented Rust sources".to_owned())?;
220    let target_directory = rust_target_directory(project_root);
221    let metadata = RustBuildCacheMetadata {
222        schema_version: RUST_BUILD_CACHE_SCHEMA_VERSION,
223        key: key.into(),
224        created_at: created_at.into(),
225        source_files,
226        instrumented_source_sha256,
227        artifacts: fingerprint_artifacts(&target_directory, artifacts)?,
228        manifest: manifest.clone(),
229    };
230    let mut bytes = serde_json::to_vec_pretty(&metadata).map_err(|error| error.to_string())?;
231    bytes.push(b'\n');
232    atomic_write(project_root, &workspace.join(CACHE_FILE), &bytes)
233        .map_err(|error| error.to_string())?;
234    Ok(metadata)
235}
236
237pub fn rust_target_directory(project_root: &Path) -> PathBuf {
238    project_root.join(".supercov/cache/rust-target")
239}
240
241#[cfg(test)]
242mod tests {
243    use std::time::{SystemTime, UNIX_EPOCH};
244
245    use super::*;
246    use crate::{coverage_analysis::PointKind, coverage_report::PointMeta};
247
248    #[test]
249    fn cache_requires_exact_key_regular_workspace_target_and_sorted_sources() {
250        let nonce = SystemTime::now()
251            .duration_since(UNIX_EPOCH)
252            .unwrap()
253            .as_nanos();
254        let root = std::env::temp_dir().join(format!(
255            "supercov-rust-cache-{}-{nonce}",
256            std::process::id()
257        ));
258        let workspace = root.join(".supercov/cache/workspace/project");
259        let target = rust_target_directory(&root);
260        fs::create_dir_all(workspace.join(".supercov")).unwrap();
261        fs::create_dir_all(workspace.join("src")).unwrap();
262        fs::create_dir_all(workspace.join("tests")).unwrap();
263        fs::write(workspace.join("src/lib.rs"), "fn work() {}\n").unwrap();
264        fs::write(workspace.join("tests/test.rs"), "#[test] fn works() {}\n").unwrap();
265        fs::create_dir_all(target.join("debug")).unwrap();
266        fs::write(target.join("debug/test-bin"), b"binary").unwrap();
267        let manifest = CoverageManifest {
268            unmeasured: Vec::new(),
269            decisions: Vec::new(),
270            points: vec![PointMeta {
271                id: "rs:statement:000000000000000000000000".into(),
272                kind: PointKind::Statement,
273                file: "src/lib.rs".into(),
274                line: 1,
275                column: 0,
276                source: "work();".into(),
277                label: None,
278            }],
279            branches: Vec::new(),
280            limitations: Vec::new(),
281            scope: None,
282        };
283        let written = write_rust_build_cache(
284            &root,
285            &workspace,
286            "key",
287            "time",
288            &["tests/test.rs".into(), "src/lib.rs".into()],
289            &manifest,
290            &[target.join("debug/test-bin")],
291        )
292        .unwrap();
293        assert_eq!(written.source_files, ["src/lib.rs", "tests/test.rs"]);
294        assert_eq!(
295            read_rust_build_cache(&workspace, &target, "key"),
296            Some(written)
297        );
298        assert!(read_rust_build_cache(&workspace, &target, "other").is_none());
299        fs::write(workspace.join("src/lib.rs"), "fn changed() {}\n").unwrap();
300        assert!(read_rust_build_cache(&workspace, &target, "key").is_none());
301        fs::write(workspace.join("src/lib.rs"), "fn work() {}\n").unwrap();
302        assert!(read_rust_build_cache(&workspace, &target, "key").is_some());
303        fs::write(target.join("debug/test-bin"), b"tamper").unwrap();
304        assert!(read_rust_build_cache(&workspace, &target, "key").is_none());
305        fs::remove_dir_all(target).unwrap();
306        assert!(read_rust_build_cache(&workspace, &root.join("missing"), "key").is_none());
307        fs::remove_dir_all(root).unwrap();
308    }
309}