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