1use 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 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 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#[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}