rta-core 0.3.0

Core analyzers and report model for the Rust Technical Audit Toolkit
Documentation
use std::{
    collections::BTreeMap,
    fs,
    io::Read,
    path::{Path, PathBuf},
    process::{Command, Stdio},
    thread,
    time::{Duration, Instant},
};

use cargo_metadata::DependencyKind;
use toml::Value;

#[derive(Debug, Clone)]
pub struct RepositorySnapshot {
    pub root: PathBuf,
    pub files: Vec<FileSnapshot>,
    pub manifests: Vec<CargoManifest>,
}

#[derive(Debug, Clone)]
pub struct FileSnapshot {
    pub path: PathBuf,
    pub relative_path: String,
    pub extension: Option<String>,
    pub bytes: u64,
    pub lines: usize,
    pub content: Option<String>,
}

#[derive(Debug, Clone)]
pub struct CargoManifest {
    pub relative_path: String,
    pub package_name: Option<String>,
    pub workspace_members: Vec<String>,
    pub dependencies: BTreeMap<String, String>,
    pub dev_dependencies: BTreeMap<String, String>,
    pub build_dependencies: BTreeMap<String, String>,
}

impl RepositorySnapshot {
    pub fn collect(root: &Path) -> Result<Self, String> {
        let root = root
            .canonicalize()
            .map_err(|err| format!("failed to resolve {}: {err}", root.display()))?;
        let mut files = Vec::new();
        walk(&root, &root, &mut files)?;

        let parsed_manifests = files
            .iter()
            .filter(|file| file.relative_path.ends_with("Cargo.toml"))
            .filter_map(|file| file.content.as_deref().map(|content| (file, content)))
            .map(|(file, content)| CargoManifest::parse(&file.relative_path, content))
            .collect::<Result<Vec<_>, _>>()?;
        let manifests = collect_metadata_manifests(&root).unwrap_or(parsed_manifests);

        Ok(Self {
            root,
            files,
            manifests,
        })
    }

    pub fn rust_files(&self) -> impl Iterator<Item = &FileSnapshot> {
        self.files
            .iter()
            .filter(|file| file.extension.as_deref() == Some("rs"))
    }
}

fn collect_metadata_manifests(root: &Path) -> Option<Vec<CargoManifest>> {
    if std::env::var_os("RTA_DISABLE_CARGO_METADATA").is_some() {
        return None;
    }
    let manifest_path = root.join("Cargo.toml");
    if !manifest_path.exists() {
        return None;
    }

    let metadata = cargo_metadata_with_timeout(root, &manifest_path)?;

    let workspace_members = metadata
        .workspace_members
        .iter()
        .filter_map(|member_id| {
            metadata
                .packages
                .iter()
                .find(|package| &package.id == member_id)
        })
        .filter_map(|package| relative_manifest_path(root, package.manifest_path.as_std_path()))
        .map(|relative| relative.trim_end_matches("/Cargo.toml").to_string())
        .collect::<Vec<_>>();
    let mut manifests = Vec::new();

    for package in metadata.packages {
        let relative_path = relative_manifest_path(root, package.manifest_path.as_std_path())?;
        let mut manifest = CargoManifest {
            relative_path,
            package_name: Some(package.name.to_string()),
            workspace_members: Vec::new(),
            dependencies: BTreeMap::new(),
            dev_dependencies: BTreeMap::new(),
            build_dependencies: BTreeMap::new(),
        };

        if package.manifest_path.as_std_path() == manifest_path {
            manifest.workspace_members = workspace_members.clone();
        }

        for dependency in package.dependencies {
            let value = dependency_value_from_metadata(&dependency);
            match dependency.kind {
                DependencyKind::Normal => {
                    manifest.dependencies.insert(dependency.name, value);
                }
                DependencyKind::Development => {
                    manifest.dev_dependencies.insert(dependency.name, value);
                }
                DependencyKind::Build => {
                    manifest.build_dependencies.insert(dependency.name, value);
                }
                _ => {}
            }
        }

        manifests.push(manifest);
    }

    Some(manifests)
}

fn cargo_metadata_with_timeout(
    root: &Path,
    manifest_path: &Path,
) -> Option<cargo_metadata::Metadata> {
    let mut child = Command::new("cargo")
        .arg("metadata")
        .arg("--no-deps")
        .arg("--format-version")
        .arg("1")
        .arg("--manifest-path")
        .arg(manifest_path)
        .current_dir(root)
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()
        .ok()?;
    let started = Instant::now();
    loop {
        match child.try_wait().ok()? {
            Some(status) if status.success() => break,
            Some(_) => return None,
            None if started.elapsed() > Duration::from_secs(5) => {
                let _ = child.kill();
                let _ = child.wait();
                return None;
            }
            None => thread::sleep(Duration::from_millis(50)),
        }
    }

    let mut output = String::new();
    child.stdout.take()?.read_to_string(&mut output).ok()?;
    serde_json::from_str(&output).ok()
}

fn dependency_value_from_metadata(dependency: &cargo_metadata::Dependency) -> String {
    if let Some(path) = &dependency.path {
        return format!("path = {}", path);
    }
    if let Some(source) = &dependency.source {
        return format!("{} ({source})", dependency.req);
    }
    dependency.req.to_string()
}

fn relative_manifest_path(root: &Path, manifest_path: &Path) -> Option<String> {
    Some(
        manifest_path
            .strip_prefix(root)
            .ok()?
            .to_string_lossy()
            .replace('\\', "/"),
    )
}

impl CargoManifest {
    fn parse(relative_path: &str, content: &str) -> Result<Self, String> {
        let parsed = content
            .parse::<Value>()
            .map_err(|err| format!("failed to parse {relative_path}: {err}"))?;
        let package_name = parsed
            .get("package")
            .and_then(|package| package.get("name"))
            .and_then(Value::as_str)
            .map(str::to_string);
        let workspace_members = parsed
            .get("workspace")
            .and_then(|workspace| workspace.get("members"))
            .and_then(Value::as_array)
            .map(|members| {
                members
                    .iter()
                    .filter_map(Value::as_str)
                    .map(str::to_string)
                    .collect()
            })
            .unwrap_or_default();
        let dependencies = parse_dependency_table(&parsed, "dependencies");
        let dev_dependencies = parse_dependency_table(&parsed, "dev-dependencies");
        let build_dependencies = parse_dependency_table(&parsed, "build-dependencies");

        Ok(Self {
            relative_path: relative_path.to_string(),
            package_name,
            workspace_members,
            dependencies,
            dev_dependencies,
            build_dependencies,
        })
    }
}

fn walk(root: &Path, current: &Path, files: &mut Vec<FileSnapshot>) -> Result<(), String> {
    let entries = fs::read_dir(current)
        .map_err(|err| format!("failed to read {}: {err}", current.display()))?;

    for entry in entries {
        let entry = entry.map_err(|err| format!("failed to read directory entry: {err}"))?;
        let path = entry.path();
        let name = entry.file_name();
        let name = name.to_string_lossy();

        if should_skip(&name) {
            continue;
        }

        let metadata = fs::symlink_metadata(&path)
            .map_err(|err| format!("failed to stat {}: {err}", path.display()))?;
        if metadata.file_type().is_symlink() {
            continue;
        }
        if metadata.is_dir() {
            walk(root, &path, files)?;
            continue;
        }
        if !metadata.is_file() {
            continue;
        }

        let relative_path = path
            .strip_prefix(root)
            .unwrap_or(&path)
            .to_string_lossy()
            .replace('\\', "/");
        let extension = path
            .extension()
            .and_then(|ext| ext.to_str())
            .map(|ext| ext.to_ascii_lowercase());
        let content = if should_read_content(extension.as_deref()) {
            read_text_file(&path, metadata.len())
        } else {
            None
        };
        let lines = content
            .as_deref()
            .map(|content| content.lines().count())
            .unwrap_or(0);

        files.push(FileSnapshot {
            path,
            relative_path,
            extension,
            bytes: metadata.len(),
            lines,
            content,
        });
    }

    Ok(())
}

fn should_skip(name: &str) -> bool {
    matches!(
        name,
        ".git" | "target" | "node_modules" | ".idea" | ".vscode" | ".DS_Store"
    )
}

fn read_text_file(path: &Path, bytes: u64) -> Option<String> {
    if bytes > 1_000_000 {
        return None;
    }
    fs::read_to_string(path).ok()
}

fn should_read_content(extension: Option<&str>) -> bool {
    matches!(extension, Some("rs" | "toml"))
}

fn parse_dependency_table(manifest: &Value, section: &str) -> BTreeMap<String, String> {
    manifest
        .get(section)
        .and_then(Value::as_table)
        .map(|dependencies| {
            dependencies
                .iter()
                .map(|(name, value)| (name.clone(), dependency_value(value)))
                .collect()
        })
        .unwrap_or_default()
}

fn dependency_value(value: &Value) -> String {
    value
        .as_str()
        .map(str::to_string)
        .unwrap_or_else(|| value.to_string())
}

#[cfg(test)]
mod tests {
    use super::CargoManifest;

    #[test]
    fn parses_multiline_workspace_members() {
        let manifest = CargoManifest::parse(
            "Cargo.toml",
            r#"
[workspace]
members = [
    "crates/audit-core",
    "crates/audit-cli",
]
"#,
        )
        .expect("workspace manifest should parse");

        assert_eq!(
            manifest.workspace_members,
            ["crates/audit-core", "crates/audit-cli"]
        );
    }

    #[test]
    fn parses_dependency_tables_without_losing_path_or_git_signals() {
        let manifest = CargoManifest::parse(
            "Cargo.toml",
            r#"
[package]
name = "fixture"

[dependencies]
serde = "1"
local-helper = { path = "../local-helper" }
remote-helper = { git = "https://example.test/repo.git", rev = "abc123" }

[dev-dependencies]
axum = { version = "0.7", features = ["json"] }

[build-dependencies]
cc = "1"
"#,
        )
        .expect("dependency manifest should parse");

        assert_eq!(manifest.package_name.as_deref(), Some("fixture"));
        assert_eq!(manifest.dependencies["serde"], "1");
        assert!(manifest.dependencies["local-helper"].contains("path"));
        assert!(manifest.dependencies["remote-helper"].contains("git"));
        assert!(manifest.dev_dependencies["axum"].contains("\"0.7\""));
        assert_eq!(manifest.build_dependencies["cc"], "1");
    }

    #[test]
    fn reports_invalid_toml() {
        let err = CargoManifest::parse("Cargo.toml", "[dependencies")
            .expect_err("invalid TOML should be rejected");

        assert!(err.contains("failed to parse Cargo.toml"));
    }
}