Skip to main content

supercov_engine/
rust_project.rs

1//! Cargo workspace discovery and isolated owned-Rust frontend preparation.
2
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    fs,
6    path::{Component, Path, PathBuf},
7    process::Command,
8};
9
10use serde::Deserialize;
11use sha2::{Digest, Sha256};
12
13use crate::{
14    coverage_report::CoverageManifest, rust_instrumenter::instrument_rust_source,
15    rust_runtime::render_rust_runtime,
16};
17
18#[derive(Debug, Clone, PartialEq)]
19pub struct PreparedRustProject {
20    pub workspace_root: PathBuf,
21    pub target_directory: PathBuf,
22    pub source_files: Vec<String>,
23    pub crate_roots: Vec<String>,
24    pub runtime_module: String,
25    pub manifest: CoverageManifest,
26}
27
28#[derive(Debug)]
29pub enum RustProjectError {
30    Io { path: PathBuf, reason: String },
31    MetadataLaunch(String),
32    MetadataFailed(String),
33    MetadataJson(String),
34    UnsafePath(String),
35    NoWorkspacePackages,
36    NoSourceFiles,
37    Instrument { file: String, reason: String },
38    DuplicateObligation(String),
39    Runtime(String),
40}
41
42impl std::fmt::Display for RustProjectError {
43    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::Io { path, reason } => write!(formatter, "{}: {reason}", path.display()),
46            Self::MetadataLaunch(reason) => {
47                write!(formatter, "could not launch cargo metadata: {reason}")
48            }
49            Self::MetadataFailed(reason) => write!(formatter, "cargo metadata failed: {reason}"),
50            Self::MetadataJson(reason) => write!(formatter, "invalid cargo metadata: {reason}"),
51            Self::UnsafePath(path) => {
52                write!(formatter, "Cargo reported an unsafe workspace path: {path}")
53            }
54            Self::NoWorkspacePackages => {
55                write!(formatter, "Cargo metadata reported no workspace packages")
56            }
57            Self::NoSourceFiles => write!(
58                formatter,
59                "Cargo workspace contains no owned Rust source files"
60            ),
61            Self::Instrument { file, reason } => {
62                write!(formatter, "could not instrument {file}: {reason}")
63            }
64            Self::DuplicateObligation(id) => {
65                write!(formatter, "duplicate Rust obligation ID: {id}")
66            }
67            Self::Runtime(reason) => write!(formatter, "could not generate Rust runtime: {reason}"),
68        }
69    }
70}
71
72impl std::error::Error for RustProjectError {}
73
74#[derive(Deserialize)]
75struct CargoMetadata {
76    packages: Vec<CargoPackage>,
77    workspace_members: Vec<String>,
78    workspace_root: PathBuf,
79    target_directory: PathBuf,
80}
81
82#[derive(Deserialize)]
83struct CargoPackage {
84    id: String,
85    manifest_path: PathBuf,
86    targets: Vec<CargoTarget>,
87}
88
89#[derive(Deserialize)]
90struct CargoTarget {
91    kind: Vec<String>,
92    src_path: PathBuf,
93}
94
95fn canonical_directory(path: &Path) -> Result<PathBuf, RustProjectError> {
96    fs::canonicalize(path).map_err(|error| RustProjectError::Io {
97        path: path.to_owned(),
98        reason: error.to_string(),
99    })
100}
101
102fn confined_relative(root: &Path, path: &Path) -> Result<String, RustProjectError> {
103    let relative = path
104        .strip_prefix(root)
105        .map_err(|_| RustProjectError::UnsafePath(path.display().to_string()))?;
106    if relative.as_os_str().is_empty()
107        || relative
108            .components()
109            .any(|component| !matches!(component, Component::Normal(_)))
110    {
111        return Err(RustProjectError::UnsafePath(path.display().to_string()));
112    }
113    Ok(relative.to_string_lossy().replace('\\', "/"))
114}
115
116fn cargo_metadata(root: &Path) -> Result<CargoMetadata, RustProjectError> {
117    let target_directory = root.join(".supercov/rust-target");
118    let output = Command::new("cargo")
119        .args(["metadata", "--format-version=1", "--no-deps"])
120        .current_dir(root)
121        .env("CARGO_TARGET_DIR", &target_directory)
122        .output()
123        .map_err(|error| RustProjectError::MetadataLaunch(error.to_string()))?;
124    if !output.status.success() {
125        return Err(RustProjectError::MetadataFailed(
126            String::from_utf8_lossy(&output.stderr).trim().to_owned(),
127        ));
128    }
129    serde_json::from_slice(&output.stdout)
130        .map_err(|error| RustProjectError::MetadataJson(error.to_string()))
131}
132
133fn collect_rust_files(
134    directory: &Path,
135    files: &mut BTreeSet<PathBuf>,
136) -> Result<(), RustProjectError> {
137    let mut entries = fs::read_dir(directory)
138        .map_err(|error| RustProjectError::Io {
139            path: directory.to_owned(),
140            reason: error.to_string(),
141        })?
142        .collect::<Result<Vec<_>, _>>()
143        .map_err(|error| RustProjectError::Io {
144            path: directory.to_owned(),
145            reason: error.to_string(),
146        })?;
147    entries.sort_by_key(fs::DirEntry::file_name);
148    for entry in entries {
149        let name = entry.file_name();
150        let name = name.to_string_lossy();
151        if matches!(name.as_ref(), ".git" | ".supercov" | "target") {
152            continue;
153        }
154        let file_type = entry.file_type().map_err(|error| RustProjectError::Io {
155            path: entry.path(),
156            reason: error.to_string(),
157        })?;
158        if file_type.is_symlink() {
159            return Err(RustProjectError::UnsafePath(
160                entry.path().display().to_string(),
161            ));
162        }
163        if file_type.is_dir() {
164            collect_rust_files(&entry.path(), files)?;
165        } else if file_type.is_file()
166            && entry.path().extension().and_then(|value| value.to_str()) == Some("rs")
167            && entry.file_name() != "build.rs"
168        {
169            files.insert(entry.path());
170        }
171    }
172    Ok(())
173}
174
175/// Read-only Cargo workspace source discovery used by integrity checks. This
176/// deliberately shares the same path policy as transformation preparation.
177pub fn discover_rust_source_files(workspace: &Path) -> Result<Vec<String>, RustProjectError> {
178    let workspace = canonical_directory(workspace)?;
179    let metadata = cargo_metadata(&workspace)?;
180    let metadata_root = canonical_directory(&metadata.workspace_root)?;
181    if metadata_root != workspace {
182        return Err(RustProjectError::UnsafePath(
183            metadata.workspace_root.display().to_string(),
184        ));
185    }
186    let members = metadata
187        .workspace_members
188        .into_iter()
189        .collect::<BTreeSet<_>>();
190    let packages = metadata
191        .packages
192        .into_iter()
193        .filter(|package| members.contains(&package.id))
194        .collect::<Vec<_>>();
195    if packages.is_empty() {
196        return Err(RustProjectError::NoWorkspacePackages);
197    }
198    let mut files = BTreeSet::new();
199    for package in packages {
200        let directory = package.manifest_path.parent().ok_or_else(|| {
201            RustProjectError::UnsafePath(package.manifest_path.display().to_string())
202        })?;
203        let directory = canonical_directory(directory)?;
204        confined_relative(&workspace, &directory).or_else(|error| {
205            (directory == workspace)
206                .then_some(String::new())
207                .ok_or(error)
208        })?;
209        collect_rust_files(&directory, &mut files)?;
210    }
211    if files.is_empty() {
212        return Err(RustProjectError::NoSourceFiles);
213    }
214    files
215        .into_iter()
216        .map(|path| confined_relative(&workspace, &path))
217        .collect()
218}
219
220fn runtime_module_name(sources: &BTreeMap<String, String>) -> String {
221    let mut suffix = 0_usize;
222    loop {
223        let candidate = if suffix == 0 {
224            "__supercov_runtime_v1".to_owned()
225        } else {
226            format!("__supercov_runtime_v1_{suffix}")
227        };
228        if sources.values().all(|source| !source.contains(&candidate)) {
229            return candidate;
230        }
231        suffix += 1;
232    }
233}
234
235fn crate_key(path: &str) -> String {
236    let digest = Sha256::digest(path.as_bytes());
237    digest[..12]
238        .iter()
239        .map(|byte| format!("{byte:02x}"))
240        .collect()
241}
242
243fn merge_manifest(
244    destination: &mut CoverageManifest,
245    mut source: CoverageManifest,
246) -> Result<(), RustProjectError> {
247    let mut ids = destination
248        .points
249        .iter()
250        .map(|point| point.id.as_str())
251        .chain(
252            destination
253                .decisions
254                .iter()
255                .map(|decision| decision.id.as_str()),
256        )
257        .chain(destination.branches.iter().map(|branch| branch.id.as_str()))
258        .collect::<BTreeSet<_>>();
259    for id in source
260        .points
261        .iter()
262        .map(|point| point.id.as_str())
263        .chain(source.decisions.iter().map(|decision| decision.id.as_str()))
264        .chain(source.branches.iter().map(|branch| branch.id.as_str()))
265    {
266        if !ids.insert(id) {
267            return Err(RustProjectError::DuplicateObligation(id.into()));
268        }
269    }
270    destination.points.append(&mut source.points);
271    destination.decisions.append(&mut source.decisions);
272    destination.branches.append(&mut source.branches);
273    for limitation in source.limitations {
274        let id = limitation.get("id").and_then(|value| value.as_str());
275        if !destination
276            .limitations
277            .iter()
278            .any(|existing| existing.get("id").and_then(|value| value.as_str()) == id)
279        {
280            destination.limitations.push(limitation);
281        }
282    }
283    Ok(())
284}
285
286pub fn prepare_rust_project(workspace: &Path) -> Result<PreparedRustProject, RustProjectError> {
287    let workspace = canonical_directory(workspace)?;
288    let metadata = cargo_metadata(&workspace)?;
289    let metadata_root = canonical_directory(&metadata.workspace_root)?;
290    if metadata_root != workspace {
291        return Err(RustProjectError::UnsafePath(
292            metadata.workspace_root.display().to_string(),
293        ));
294    }
295    let members = metadata
296        .workspace_members
297        .into_iter()
298        .collect::<BTreeSet<_>>();
299    let packages = metadata
300        .packages
301        .into_iter()
302        .filter(|package| members.contains(&package.id))
303        .collect::<Vec<_>>();
304    if packages.is_empty() {
305        return Err(RustProjectError::NoWorkspacePackages);
306    }
307
308    let mut files = BTreeSet::new();
309    let mut roots = BTreeSet::new();
310    for package in &packages {
311        let directory = package.manifest_path.parent().ok_or_else(|| {
312            RustProjectError::UnsafePath(package.manifest_path.display().to_string())
313        })?;
314        let directory = canonical_directory(directory)?;
315        confined_relative(&workspace, &directory).or_else(|error| {
316            (directory == workspace)
317                .then_some(String::new())
318                .ok_or(error)
319        })?;
320        collect_rust_files(&directory, &mut files)?;
321        for target in &package.targets {
322            if target.kind.iter().any(|kind| kind == "custom-build") {
323                continue;
324            }
325            let root =
326                fs::canonicalize(&target.src_path).map_err(|error| RustProjectError::Io {
327                    path: target.src_path.clone(),
328                    reason: error.to_string(),
329                })?;
330            confined_relative(&workspace, &root)?;
331            roots.insert(root);
332        }
333    }
334    if files.is_empty() {
335        return Err(RustProjectError::NoSourceFiles);
336    }
337
338    let mut sources = BTreeMap::new();
339    for path in files {
340        let relative = confined_relative(&workspace, &path)?;
341        let source = fs::read_to_string(&path).map_err(|error| RustProjectError::Io {
342            path: path.clone(),
343            reason: error.to_string(),
344        })?;
345        sources.insert(relative, source);
346    }
347    let runtime_module = runtime_module_name(&sources);
348    let runtime_path = format!("crate::{runtime_module}");
349    let mut manifest = CoverageManifest {
350        unmeasured: Vec::new(),
351        decisions: Vec::new(),
352        points: Vec::new(),
353        branches: Vec::new(),
354        limitations: Vec::new(),
355        scope: None,
356    };
357    for (relative, source) in &sources {
358        let transformed =
359            instrument_rust_source(relative, source, &runtime_path).map_err(|error| {
360                RustProjectError::Instrument {
361                    file: relative.clone(),
362                    reason: error.to_string(),
363                }
364            })?;
365        merge_manifest(&mut manifest, transformed.manifest)?;
366        fs::write(workspace.join(relative), transformed.code).map_err(|error| {
367            RustProjectError::Io {
368                path: workspace.join(relative),
369                reason: error.to_string(),
370            }
371        })?;
372    }
373
374    let mut crate_roots = Vec::new();
375    for root in roots {
376        let relative = confined_relative(&workspace, &root)?;
377        let runtime = render_rust_runtime(&runtime_module, &crate_key(&relative))
378            .map_err(RustProjectError::Runtime)?;
379        let mut source = fs::read_to_string(&root).map_err(|error| RustProjectError::Io {
380            path: root.clone(),
381            reason: error.to_string(),
382        })?;
383        source.push('\n');
384        source.push_str(&runtime);
385        fs::write(&root, source).map_err(|error| RustProjectError::Io {
386            path: root,
387            reason: error.to_string(),
388        })?;
389        crate_roots.push(relative);
390    }
391
392    manifest
393        .points
394        .sort_by(|left, right| left.id.cmp(&right.id));
395    manifest
396        .decisions
397        .sort_by(|left, right| left.id.cmp(&right.id));
398    manifest
399        .branches
400        .sort_by(|left, right| left.id.cmp(&right.id));
401    manifest.limitations.sort_by(|left, right| {
402        left.get("id")
403            .and_then(|value| value.as_str())
404            .cmp(&right.get("id").and_then(|value| value.as_str()))
405    });
406    let target_directory = metadata.target_directory;
407    let target_directory = if target_directory.is_absolute() {
408        target_directory
409    } else {
410        workspace.join(target_directory)
411    };
412    if !target_directory.starts_with(&workspace) {
413        return Err(RustProjectError::UnsafePath(
414            target_directory.display().to_string(),
415        ));
416    }
417    Ok(PreparedRustProject {
418        workspace_root: workspace,
419        target_directory,
420        source_files: sources.into_keys().collect(),
421        crate_roots,
422        runtime_module,
423        manifest,
424    })
425}
426
427#[cfg(test)]
428mod tests {
429    use std::{
430        process::Command,
431        sync::atomic::{AtomicU64, Ordering},
432        time::{SystemTime, UNIX_EPOCH},
433    };
434
435    use super::*;
436
437    fn fixture() -> PathBuf {
438        // One test calls this today, so nothing can collide with it yet. The
439        // counter is here because the clock is not enough on its own: it ticks
440        // once per microsecond and every test shares the pid, so the second
441        // test to use this helper would draw the same root as the first when
442        // the two start together.
443        static UNIQUE: AtomicU64 = AtomicU64::new(0);
444        let nonce = SystemTime::now()
445            .duration_since(UNIX_EPOCH)
446            .unwrap()
447            .as_nanos();
448        let root = std::env::temp_dir().join(format!(
449            "supercov-rust-project-{}-{nonce}-{}",
450            std::process::id(),
451            UNIQUE.fetch_add(1, Ordering::Relaxed)
452        ));
453        fs::create_dir(&root).unwrap();
454        fs::create_dir(root.join("src")).unwrap();
455        fs::create_dir(root.join("tests")).unwrap();
456        fs::write(
457            root.join("Cargo.toml"),
458            "[package]\nname='rust-project-fixture'\nversion='0.0.0'\nedition='2024'\n",
459        )
460        .unwrap();
461        fs::write(
462            root.join("src/lib.rs"),
463            r#"pub fn choose(first: bool, second: bool) -> i32 {
464    if first && second { 7 } else { 3 }
465}
466
467#[cfg(test)]
468mod tests {
469    #[test]
470    fn unit_choice() {
471        assert_eq!(super::choose(true, true), 7);
472    }
473}
474"#,
475        )
476        .unwrap();
477        fs::write(
478            root.join("tests/integration.rs"),
479            r#"#[test]
480fn integration_choice() {
481    assert_eq!(rust_project_fixture::choose(false, true), 3);
482}
483"#,
484        )
485        .unwrap();
486        root
487    }
488
489    #[test]
490    fn prepares_every_workspace_crate_root_and_compiles_without_manifest_changes() {
491        let root = fixture();
492        let manifest_before = fs::read(root.join("Cargo.toml")).unwrap();
493        let prepared = prepare_rust_project(&root).unwrap();
494        assert_eq!(
495            prepared.source_files,
496            ["src/lib.rs", "tests/integration.rs"]
497        );
498        assert_eq!(prepared.crate_roots, ["src/lib.rs", "tests/integration.rs"]);
499        assert!(!prepared.manifest.points.is_empty());
500        assert!(!prepared.manifest.decisions.is_empty());
501        assert_eq!(fs::read(root.join("Cargo.toml")).unwrap(), manifest_before);
502        for crate_root in &prepared.crate_roots {
503            assert!(
504                fs::read_to_string(root.join(crate_root))
505                    .unwrap()
506                    .contains(&format!("mod {}", prepared.runtime_module))
507            );
508        }
509        let build = Command::new("cargo")
510            .args(["test", "--no-run"])
511            .current_dir(&root)
512            .env("CARGO_TARGET_DIR", &prepared.target_directory)
513            .output()
514            .unwrap();
515        assert!(
516            build.status.success(),
517            "{}",
518            String::from_utf8_lossy(&build.stderr)
519        );
520        fs::remove_dir_all(root).unwrap();
521    }
522}