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 = 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 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}