use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use async_trait::async_trait;
use super::provider::{DependencyProvider, DependencySourceType, DiscoveredDependency};
const CARGO_METADATA_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
async fn run_cargo_metadata(manifest_path: &Path) -> Result<cargo_metadata::Metadata> {
let mut last_err = None;
for flags in [&["--offline", "--locked"][..], &["--offline"][..]] {
let mut cmd = cargo_metadata::MetadataCommand::new();
cmd.manifest_path(manifest_path)
.other_options(flags.iter().map(|f| f.to_string()).collect::<Vec<_>>());
let mut cmd = tokio::process::Command::from(cmd.cargo_command());
cmd.kill_on_drop(true)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let child = cmd.spawn().context("Failed to spawn cargo")?;
let output = tokio::time::timeout(CARGO_METADATA_TIMEOUT, child.wait_with_output())
.await
.map_err(|_| {
anyhow::anyhow!(
"cargo metadata timed out after {}s",
CARGO_METADATA_TIMEOUT.as_secs()
)
})??;
if output.status.success() {
let stdout = String::from_utf8(output.stdout)?;
let json = stdout
.lines()
.find(|line| line.starts_with('{'))
.context("cargo metadata produced no JSON")?;
return Ok(cargo_metadata::MetadataCommand::parse(json)?);
}
last_err = Some(anyhow::anyhow!(
"cargo metadata {} failed: {}",
flags.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
));
}
Err(last_err.expect("at least one attempt ran"))
}
#[derive(Debug, Default, Clone)]
pub struct CargoDependencyProvider;
impl CargoDependencyProvider {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl DependencyProvider for CargoDependencyProvider {
fn ecosystem(&self) -> &'static str {
"cargo"
}
async fn discover_dependencies(&self, root: &Path) -> Result<Vec<DiscoveredDependency>> {
let manifest_path = if root.is_file() {
root.to_path_buf()
} else {
root.join("Cargo.toml")
};
if !manifest_path.exists() {
anyhow::bail!("Cargo manifest not found at {}", manifest_path.display());
}
let metadata = run_cargo_metadata(&manifest_path).await.with_context(|| {
format!(
"Failed to execute cargo metadata for {}",
manifest_path.display()
)
})?;
let lock_path = metadata.workspace_root.join("Cargo.lock");
let lock_checksums = if lock_path.exists() {
std::fs::read_to_string(lock_path.as_std_path())
.map(|content| parse_lockfile_checksums(&content))
.unwrap_or_default()
} else {
HashMap::new()
};
let workspace_members: HashSet<_> = metadata.workspace_members.iter().collect();
let mut direct_package_ids = HashSet::new();
let mut package_features: HashMap<&cargo_metadata::PackageId, Vec<String>> = HashMap::new();
if let Some(ref resolve) = metadata.resolve {
for node in &resolve.nodes {
let feats: Vec<String> = node.features.iter().map(|f| f.to_string()).collect();
package_features.insert(&node.id, feats);
if workspace_members.contains(&node.id) {
for dep in &node.deps {
direct_package_ids.insert(&dep.pkg);
}
}
}
}
let mut discovered = Vec::new();
for pkg in &metadata.packages {
if workspace_members.contains(&pkg.id) {
continue; }
let source_type = match &pkg.source {
Some(src) => {
let s = src.to_string();
if s.starts_with("registry+") {
DependencySourceType::CratesIo
} else if s.starts_with("git+") {
DependencySourceType::Git
} else {
DependencySourceType::Unknown(s)
}
}
None => DependencySourceType::Path,
};
let manifest_path = pkg.manifest_path.as_std_path().to_path_buf();
let root_path = manifest_path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| manifest_path.clone());
let package_hash = lock_checksums
.get(&(pkg.name.to_string(), pkg.version.to_string()))
.cloned();
let is_direct = direct_package_ids.contains(&pkg.id);
let features = package_features.get(&pkg.id).cloned().unwrap_or_default();
discovered.push(DiscoveredDependency {
name: pkg.name.to_string(),
version: pkg.version.to_string(),
source_type,
manifest_path,
root_path,
package_hash,
is_direct,
features,
});
}
discovered.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version)));
Ok(discovered)
}
fn resolve_source_directory(&self, dep: &DiscoveredDependency) -> Result<PathBuf> {
if dep.root_path.exists() && dep.root_path.is_dir() {
Ok(dep.root_path.clone())
} else if dep.manifest_path.exists() {
Ok(dep
.manifest_path
.parent()
.unwrap_or(&dep.manifest_path)
.to_path_buf())
} else {
anyhow::bail!(
"Unpacked source directory does not exist for dependency {} v{} at {}",
dep.name,
dep.version,
dep.root_path.display()
)
}
}
}
fn parse_lockfile_checksums(content: &str) -> HashMap<(String, String), String> {
let mut map = HashMap::new();
let mut current_name = None;
let mut current_version = None;
let mut current_checksum = None;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == "[[package]]" {
if let (Some(name), Some(ver), Some(chk)) = (
current_name.take(),
current_version.take(),
current_checksum.take(),
) {
map.insert((name, ver), chk);
}
current_name = None;
current_version = None;
current_checksum = None;
} else if let Some(rest) = trimmed.strip_prefix("name = ") {
current_name = Some(rest.trim_matches('"').to_string());
} else if let Some(rest) = trimmed.strip_prefix("version = ") {
current_version = Some(rest.trim_matches('"').to_string());
} else if let Some(rest) = trimmed.strip_prefix("checksum = ") {
current_checksum = Some(rest.trim_matches('"').to_string());
}
}
if let (Some(name), Some(ver), Some(chk)) = (current_name, current_version, current_checksum) {
map.insert((name, ver), chk);
}
map
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_lockfile_checksums_correctly() {
let lockfile = r#"
# This file is automatically @generated by Cargo.
version = 4
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b823d03a0719213154163092b5077492f80252d76ab8103ed98bc045adc0109"
[[package]]
name = "scryer-db"
version = "0.1.0"
dependencies = [
"toasty",
]
"#;
let checksums = parse_lockfile_checksums(lockfile);
assert_eq!(
checksums.get(&("serde".to_string(), "1.0.229".to_string())),
Some(&"6b823d03a0719213154163092b5077492f80252d76ab8103ed98bc045adc0109".to_string())
);
assert_eq!(
checksums.get(&("scryer-db".to_string(), "0.1.0".to_string())),
None
);
}
}