use crate::opts;
use anyhow::{Context, Result, anyhow, bail, ensure};
use cargo_metadata::{Metadata, MetadataCommand};
use cargo_util_schemas::manifest::TomlDetailedDependency;
use dylint_internal::{CommandExt, cargo::stable_cargo_path, home::cargo_home, packaging::isolate};
use semver::Version;
use serde::Serialize;
use std::{
borrow::Cow,
collections::BTreeMap,
ffi::{OsStr, OsString},
fs::{create_dir_all, read_dir, remove_dir_all, write},
path::{Path, PathBuf},
};
use tempfile::{Builder, TempDir, tempdir};
use url::Url;
mod util;
use util::{CanonicalUrl, short_hash};
struct NamedTempDir(PathBuf);
impl Drop for NamedTempDir {
fn drop(&mut self) {
remove_dir_all(&self.0).unwrap_or_default();
}
}
pub struct GlobalContext;
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct PackageId {
name: String,
version: Version,
source_id: String,
}
pub type SourceId = String;
impl GlobalContext {
#[allow(clippy::unnecessary_wraps)]
pub const fn default() -> Result<Self> {
Ok(Self)
}
}
impl PackageId {
#[allow(clippy::unnecessary_wraps)]
pub const fn new(name: String, version: Version, source_id: SourceId) -> Self {
Self {
name,
version,
source_id,
}
}
pub fn name(&self) -> &str {
&self.name
}
}
pub fn dependency_source_id_and_root(
_opts: &opts::Dylint,
metadata: &Metadata,
_gctx: &GlobalContext,
details: &TomlDetailedDependency,
) -> Result<(SourceId, PathBuf)> {
if let Some(url) = &details.git {
ensure!(
details.path.is_none(),
"A dependency cannot have both git and path entries"
);
let source_id = git_source_id(url, details)?;
let root = git_dependency_root(url, details)?;
Ok((source_id, root))
} else if let Some(path) = &details.path {
let source_id = String::new();
let root = metadata
.workspace_root
.join(path)
.as_std_path()
.to_path_buf();
Ok((source_id, root))
} else {
bail!("Only git and path entries are supported")
}
}
fn git_source_id(url: &str, details: &TomlDetailedDependency) -> Result<String> {
#[derive(Serialize)]
struct GitReference<'a> {
url: &'a str,
branch: Option<&'a str>,
tag: Option<&'a str>,
rev: Option<&'a str>,
}
let json = serde_json::to_string(&GitReference {
url,
branch: details.branch.as_deref(),
tag: details.tag.as_deref(),
rev: details.rev.as_deref(),
})?;
Ok(json)
}
fn git_dependency_root(url: &str, details: &TomlDetailedDependency) -> Result<PathBuf> {
let dependency = create_dummy_dependency()?;
let filename = dependency
.path()
.file_name()
.ok_or_else(|| anyhow!("Could not get file name"))?;
let dep_name = filename.to_string_lossy();
let package = create_dummy_package(&dep_name, details)?;
let cargo_home = cargo_home().with_context(|| "Could not determine `CARGO_HOME`")?;
let ident = ident(url)?;
let checkout_path = cargo_home.join("git/checkouts").join(ident);
let mut errors = Vec::new();
for _ in [false, true] {
let injected_dependencies = if checkout_path.try_exists().with_context(|| {
format!(
"Could not determine whether `{}` exists",
checkout_path.display()
)
})? {
inject_dummy_dependencies(dependency.path(), &dep_name, &checkout_path)?
} else {
BTreeMap::new()
};
let output = cargo_fetch(package.path())?;
let metadata = cargo_metadata(package.path()).ok();
match find_accessed_subdir(
&dep_name,
&checkout_path,
&injected_dependencies,
metadata.as_ref(),
) {
Ok(path) => {
return Ok(path.to_path_buf());
}
Err(error) => {
let s = if output.status.success() {
error.to_string()
} else {
format!(
"{:?}",
Result::<PathBuf>::Err(error).with_context(|| {
format!(
"fetching packages failed\nstdout: {:?}\nstderr: {:?}",
String::from_utf8(output.stdout).unwrap_or_default(),
dummy_dependency_free_suffix(
&dep_name,
&String::from_utf8(output.stderr).unwrap_or_default()
)
)
})
)
};
errors.push(s);
}
}
}
debug_assert!(errors.len() >= 2);
Err(anyhow!("Could not find git dependency root: {errors:#?}"))
}
fn create_dummy_dependency() -> Result<TempDir> {
let tempdir = Builder::new()
.prefix("tmp")
.tempdir()
.with_context(|| "Could not create temporary directory")?;
dylint_internal::cargo::init("dummy dependency")
.quiet(true)
.stable(true)
.build()
.current_dir(&tempdir)
.args(["--lib", "--vcs=none"])
.success()?;
isolate(tempdir.path())?;
Ok(tempdir)
}
fn create_dummy_package(dep_name: &str, details: &TomlDetailedDependency) -> Result<TempDir> {
let tempdir = tempdir().with_context(|| "Could not create temporary directory")?;
let manifest_contents = manifest_contents(dep_name, details)?;
let manifest_path = tempdir.path().join("Cargo.toml");
write(&manifest_path, manifest_contents)
.with_context(|| format!("Could not write to `{}`", manifest_path.display()))?;
let src_path = tempdir.path().join("src");
create_dir_all(&src_path)
.with_context(|| format!("`create_dir_all` failed for `{}`", src_path.display()))?;
let main_rs_path = src_path.join("main.rs");
write(&main_rs_path, "fn main() {}")
.with_context(|| format!("Could not write to `{}`", main_rs_path.display()))?;
Ok(tempdir)
}
fn manifest_contents(dep_name: &str, details: &TomlDetailedDependency) -> Result<String> {
let details = toml::to_string(details)?;
Ok(format!(
r#"
[package]
name = "dummy-package"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies.{dep_name}]
{details}
"#
))
}
fn inject_dummy_dependencies(
dep_path: &Path,
dep_name: &str,
checkout_path: &Path,
) -> Result<BTreeMap<OsString, NamedTempDir>> {
let mut injected_dependencies = BTreeMap::new();
#[cfg_attr(dylint_lib = "general", allow(non_local_effect_before_error_return))]
for_each_subdir(checkout_path, |subdir, path| {
injected_dependencies.insert(subdir.to_owned(), NamedTempDir(path.join(dep_name)));
fs_extra::dir::copy(dep_path, path, &fs_extra::dir::CopyOptions::default())?;
Ok(())
})?;
Ok(injected_dependencies)
}
fn cargo_fetch(path: &Path) -> Result<std::process::Output> {
dylint_internal::cargo::fetch("dummy package")
.quiet(dylint_internal::cargo::Quiet::MESSAGE)
.stable(true)
.build()
.args([
"--manifest-path",
&path.join("Cargo.toml").to_string_lossy(),
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.logged_output(false)
}
fn cargo_metadata(path: &Path) -> Result<Metadata> {
MetadataCommand::new()
.cargo_path(stable_cargo_path())
.current_dir(path)
.exec()
.map_err(Into::into)
}
#[allow(clippy::manual_next_back)]
fn ident(url: &str) -> Result<String> {
let url = Url::parse(url)?;
let canonical_url = CanonicalUrl::new(&url)?;
let ident = canonical_url
.raw_canonicalized_url()
.path_segments()
.and_then(|s| s.rev().next())
.unwrap_or("");
let ident = if ident.is_empty() { "_empty" } else { ident };
Ok(format!("{}-{}", ident, short_hash(&canonical_url)))
}
fn find_accessed_subdir<'a>(
dep_name: &str,
checkout_path: &Path,
injected_dependencies: &BTreeMap<OsString, NamedTempDir>,
metadata: Option<&'a Metadata>,
) -> Result<Cow<'a, Path>> {
let mut accessed = metadata
.map_or::<&[_], _>(&[], |metadata| &metadata.packages)
.iter()
.map(|package| {
if package.name.as_str() == dep_name {
let parent = package
.manifest_path
.parent()
.ok_or_else(|| anyhow!("Could not get parent directory"))?;
let grandparent = parent
.parent()
.ok_or_else(|| anyhow!("Could not get grandparent directory"))?;
#[cfg(debug_assertions)]
eprintln!(
"{}:{:?}: accessed: {grandparent:?}",
std::process::id(),
std::thread::current().id()
);
Ok(Some(Cow::Borrowed(grandparent.as_std_path())))
} else {
Ok(None)
}
})
.filter_map(Result::transpose)
.collect::<Result<Vec<_>>>()?;
if accessed.is_empty() {
for_each_subdir(checkout_path, |subdir, path| {
if injected_dependencies.get(subdir).is_none() {
#[cfg(debug_assertions)]
eprintln!(
"{}:{:?}: pushing `{}`",
std::process::id(),
std::thread::current().id(),
path.display()
);
accessed.push(Cow::Owned(path.to_path_buf()));
}
Ok(())
})?;
}
ensure!(
accessed.len() <= 1,
"Multiple subdirectories were accessed: {:#?}",
accessed
);
accessed
.into_iter()
.next()
.ok_or_else(|| anyhow!("Could not determine accessed subdirectory"))
}
fn for_each_subdir(
checkout_path: &Path,
mut f: impl FnMut(&OsStr, &Path) -> Result<()>,
) -> Result<()> {
for entry in read_dir(checkout_path)
.with_context(|| format!("`read_dir` failed for `{}`", checkout_path.display()))?
{
let entry = entry
.with_context(|| format!("`read_dir` failed for `{}`", checkout_path.display()))?;
let path = entry.path();
let file_name = path
.file_name()
.ok_or_else(|| anyhow!("Could not get file name"))?;
if !path.is_dir() {
continue;
}
f(file_name, &path)?;
}
Ok(())
}
fn dummy_dependency_free_suffix(dep_name: &str, s: &str) -> String {
let lines = { s.split_inclusive('\n') };
if let Some(i) = lines.clone().rev().position(|line| line.contains(dep_name)) {
let n = lines.clone().count();
lines.skip(n - i).collect()
} else {
s.to_owned()
}
}