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