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