#[cfg(feature = "network")]
use std::cell::Cell;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
#[cfg(feature = "network")]
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
use cargo_metadata::MetadataCommand;
use semver::{Version, VersionReq};
#[cfg(feature = "network")]
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::finding::{EvidenceClass, Finding, Location, OneBasedLine, Origin, Severity};
use crate::ingest::{CrateInfo, DeclaredDependency, Workspace};
pub const NAME_COLLISION_RISK_RULE: &str = "name-collision-risk";
pub const NAME_COLLISION_RISK_RULE_REVISION: u32 = 1;
pub const PHANTOM_CRATE_RULE: &str = "phantom-crate";
pub const PHANTOM_CRATE_RULE_REVISION: u32 = 1;
pub const PHANTOM_VERSION_RULE: &str = "phantom-version";
pub const PHANTOM_VERSION_RULE_REVISION: u32 = 1;
pub const FRESH_LOW_REPUTATION_DEP_RULE: &str = "fresh-low-reputation-dep";
pub const FRESH_LOW_REPUTATION_DEP_RULE_REVISION: u32 = 1;
pub const YANKED_DEPENDENCY_RULE: &str = "yanked-dependency";
pub const YANKED_DEPENDENCY_RULE_REVISION: u32 = 1;
pub const DEP_SINGLE_MAINTAINER_RULE: &str = "dep-single-maintainer";
pub const DEP_SINGLE_MAINTAINER_RULE_REVISION: u32 = 1;
const MIN_MAINTAINER_COUNT: usize = 2;
const POPULAR_CRATES_RAW: &str = include_str!("../data/popular_crates.txt");
const MIN_NAME_LEN_FOR_COLLISION_CHECK: usize = 6;
const MAX_COLLISION_EDIT_DISTANCE: usize = 1;
fn popular_crate_names() -> impl Iterator<Item = &'static str> {
POPULAR_CRATES_RAW
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
}
fn normalize(name: &str) -> String {
name.to_lowercase().replace('-', "_")
}
fn is_family_pair(a: &str, b: &str) -> bool {
let (na, nb) = (normalize(a), normalize(b));
na == nb || na.starts_with(&format!("{nb}_")) || nb.starts_with(&format!("{na}_"))
}
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut curr = vec![0usize; b.len() + 1];
for i in 1..=a.len() {
curr[0] = i;
for j in 1..=b.len() {
let cost = usize::from(a[i - 1] != b[j - 1]);
curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b.len()]
}
pub fn analyze_name_collision(workspace: &Workspace) -> Vec<Finding> {
let popular: Vec<&str> = popular_crate_names().collect();
let mut findings = Vec::new();
for krate in &workspace.crates {
for dep in &krate.dependencies {
if dep.name.len() < MIN_NAME_LEN_FOR_COLLISION_CHECK {
continue;
}
let normalized_dep = normalize(&dep.name);
let mut nearest: Option<(&str, usize)> = None;
for &popular_name in &popular {
if is_family_pair(&dep.name, popular_name) {
continue;
}
let distance = levenshtein(&normalized_dep, &normalize(popular_name));
if distance == 0 || distance > MAX_COLLISION_EDIT_DISTANCE {
continue;
}
if nearest.is_none_or(|(_, best)| distance < best) {
nearest = Some((popular_name, distance));
}
}
if let Some((nearest_name, distance)) = nearest {
findings.push(name_collision_finding(krate, dep, nearest_name, distance));
}
}
}
findings
}
fn name_collision_finding(
krate: &CrateInfo,
dep: &DeclaredDependency,
nearest_popular_crate: &str,
edit_distance: usize,
) -> Finding {
Finding::new(
format!("{NAME_COLLISION_RISK_RULE}:{}:{}", krate.name, dep.name),
NAME_COLLISION_RISK_RULE,
Severity::Warn,
crate::rules::deps::dep_location(krate, dep.name.clone()),
EvidenceClass::Heuristic,
Origin::Code,
Some(serde_json::json!({
"nearest_popular_crate": nearest_popular_crate,
"edit_distance": edit_distance,
})),
)
}
#[derive(Debug)]
pub enum SlopsquatError {
Connection(Box<dyn std::error::Error + Send + Sync>),
CircuitOpen,
Other(Box<dyn std::error::Error + Send + Sync>),
}
impl std::fmt::Display for SlopsquatError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Connection(err) => write!(f, "crates.io unreachable: {err}"),
Self::CircuitOpen => {
write!(
f,
"crates.io lookups skipped after an earlier connection failure"
)
}
Self::Other(err) => write!(f, "crates.io lookup failed: {err}"),
}
}
}
impl std::error::Error for SlopsquatError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Connection(err) | Self::Other(err) => Some(err.as_ref()),
Self::CircuitOpen => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexVersion {
pub vers: String,
#[serde(default)]
pub yanked: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexEntry {
pub versions: Vec<IndexVersion>,
}
pub trait CratesIoIndex {
fn lookup(&self, crate_name: &str) -> Result<Option<IndexEntry>, SlopsquatError>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrateMetadata {
pub created_at: String,
pub downloads: u64,
pub repository: Option<String>,
}
pub trait CratesIoMetadata {
fn metadata(&self, crate_name: &str) -> Result<Option<CrateMetadata>, SlopsquatError>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrateOwner {
pub login: String,
}
pub trait CratesIoOwners {
fn owners(&self, crate_name: &str) -> Result<Option<Vec<CrateOwner>>, SlopsquatError>;
}
#[cfg(feature = "network")]
#[derive(Debug, Serialize, Deserialize)]
struct CacheRecord<T> {
fetched_at: u64,
data: T,
}
#[cfg(feature = "network")]
const CACHE_TTL_SECS: u64 = 24 * 60 * 60;
fn now_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(feature = "network")]
fn cache_path(cache_root: &Path, category: &str, crate_name: &str) -> PathBuf {
cache_root.join(category).join(format!("{crate_name}.json"))
}
#[cfg(feature = "network")]
fn read_cache<T: DeserializeOwned>(path: &Path) -> Option<T> {
let text = std::fs::read_to_string(path).ok()?;
let record: CacheRecord<T> = serde_json::from_str(&text).ok()?;
if now_unix_secs().saturating_sub(record.fetched_at) > CACHE_TTL_SECS {
return None;
}
Some(record.data)
}
#[cfg(feature = "network")]
fn write_cache<T: Serialize>(path: &Path, data: &T) {
let record = CacheRecord {
fetched_at: now_unix_secs(),
data,
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(text) = serde_json::to_string(&record) {
let _ = std::fs::write(path, text);
}
}
#[cfg(feature = "network")]
fn is_connection_error(err: &ureq::Error) -> bool {
matches!(
err,
ureq::Error::Io(_)
| ureq::Error::HostNotFound
| ureq::Error::ConnectionFailed
| ureq::Error::Timeout(_)
)
}
#[cfg(feature = "network")]
fn build_agent(user_agent: &str) -> ureq::Agent {
let config = ureq::Agent::config_builder()
.timeout_connect(Some(Duration::from_secs(3)))
.timeout_global(Some(Duration::from_secs(5)))
.user_agent(user_agent)
.build();
config.into()
}
#[cfg(feature = "network")]
const JUDGE_USER_AGENT: &str = "cargo-judge (https://github.com/casoon/judge)";
#[cfg(feature = "network")]
fn sparse_index_path(crate_name: &str) -> String {
let lower = crate_name.to_lowercase();
match lower.len() {
0 => format!("1/{lower}"),
1 => format!("1/{lower}"),
2 => format!("2/{lower}"),
3 => format!("3/{}/{lower}", &lower[..1]),
_ => format!("{}/{}/{lower}", &lower[..2], &lower[2..4]),
}
}
#[cfg(feature = "network")]
#[derive(Debug, Deserialize)]
struct RawIndexLine {
vers: String,
#[serde(default)]
yanked: bool,
}
#[cfg(feature = "network")]
struct HttpClientState {
agent: ureq::Agent,
cache_root: PathBuf,
circuit_open: Cell<bool>,
}
#[cfg(feature = "network")]
impl HttpClientState {
fn new(cache_root: PathBuf) -> Self {
Self {
agent: build_agent(JUDGE_USER_AGENT),
cache_root,
circuit_open: Cell::new(false),
}
}
}
#[cfg(feature = "network")]
fn parse_json_body<T: DeserializeOwned>(body: &str) -> Result<T, SlopsquatError> {
serde_json::from_str(body).map_err(|err| SlopsquatError::Other(err.into()))
}
#[cfg(feature = "network")]
fn cached_get<T, F>(
state: &HttpClientState,
category: &str,
crate_name: &str,
url: &str,
parse_body: F,
) -> Result<Option<T>, SlopsquatError>
where
T: Clone + Serialize + DeserializeOwned,
F: FnOnce(&str) -> Result<T, SlopsquatError>,
{
let path = cache_path(&state.cache_root, category, crate_name);
if let Some(cached) = read_cache::<Option<T>>(&path) {
return Ok(cached);
}
if state.circuit_open.get() {
return Err(SlopsquatError::CircuitOpen);
}
let mut response = match state.agent.get(url).call() {
Ok(response) => response,
Err(ureq::Error::StatusCode(404)) => {
write_cache(&path, &None::<T>);
return Ok(None);
}
Err(err) if is_connection_error(&err) => {
state.circuit_open.set(true);
return Err(SlopsquatError::Connection(err.into()));
}
Err(err) => return Err(SlopsquatError::Other(err.into())),
};
let body = response
.body_mut()
.read_to_string()
.map_err(|err| SlopsquatError::Other(err.into()))?;
let data = parse_body(&body)?;
write_cache(&path, &Some(data.clone()));
Ok(Some(data))
}
#[cfg(feature = "network")]
pub struct SparseIndexClient {
state: HttpClientState,
}
#[cfg(feature = "network")]
impl SparseIndexClient {
pub fn new(cache_root: PathBuf) -> Self {
Self {
state: HttpClientState::new(cache_root),
}
}
}
#[cfg(feature = "network")]
impl CratesIoIndex for SparseIndexClient {
fn lookup(&self, crate_name: &str) -> Result<Option<IndexEntry>, SlopsquatError> {
let url = format!("https://index.crates.io/{}", sparse_index_path(crate_name));
cached_get(&self.state, "index", crate_name, &url, |body| {
let versions: Vec<IndexVersion> = body
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<RawIndexLine>(line).ok())
.map(|raw| IndexVersion {
vers: raw.vers,
yanked: raw.yanked,
})
.collect();
Ok(IndexEntry { versions })
})
}
}
#[cfg(not(feature = "network"))]
pub struct SparseIndexClient {}
#[cfg(not(feature = "network"))]
impl SparseIndexClient {
pub fn new(_cache_root: PathBuf) -> Self {
Self {}
}
}
#[cfg(not(feature = "network"))]
impl CratesIoIndex for SparseIndexClient {
fn lookup(&self, _crate_name: &str) -> Result<Option<IndexEntry>, SlopsquatError> {
Err(SlopsquatError::Connection(Box::new(std::io::Error::other(
"cargo-judge was compiled without the 'network' feature",
))))
}
}
#[cfg(feature = "network")]
#[derive(Debug, Deserialize)]
struct RestCrateResponse {
#[serde(rename = "crate")]
krate: CrateMetadata,
}
#[cfg(feature = "network")]
pub struct RestMetadataClient {
state: HttpClientState,
}
#[cfg(feature = "network")]
impl RestMetadataClient {
pub fn new(cache_root: PathBuf) -> Self {
Self {
state: HttpClientState::new(cache_root),
}
}
}
#[cfg(feature = "network")]
impl CratesIoMetadata for RestMetadataClient {
fn metadata(&self, crate_name: &str) -> Result<Option<CrateMetadata>, SlopsquatError> {
let url = format!("https://crates.io/api/v1/crates/{crate_name}");
cached_get(&self.state, "meta", crate_name, &url, |body| {
let parsed: RestCrateResponse = parse_json_body(body)?;
Ok(parsed.krate)
})
}
}
#[cfg(not(feature = "network"))]
pub struct RestMetadataClient {}
#[cfg(not(feature = "network"))]
impl RestMetadataClient {
pub fn new(_cache_root: PathBuf) -> Self {
Self {}
}
}
#[cfg(not(feature = "network"))]
impl CratesIoMetadata for RestMetadataClient {
fn metadata(&self, _crate_name: &str) -> Result<Option<CrateMetadata>, SlopsquatError> {
Err(SlopsquatError::Connection(Box::new(std::io::Error::other(
"cargo-judge was compiled without the 'network' feature",
))))
}
}
#[cfg(feature = "network")]
#[derive(Debug, Deserialize)]
struct RestOwnersResponse {
users: Vec<CrateOwner>,
}
#[cfg(feature = "network")]
pub struct RestOwnersClient {
state: HttpClientState,
}
#[cfg(feature = "network")]
impl RestOwnersClient {
pub fn new(cache_root: PathBuf) -> Self {
Self {
state: HttpClientState::new(cache_root),
}
}
}
#[cfg(feature = "network")]
impl CratesIoOwners for RestOwnersClient {
fn owners(&self, crate_name: &str) -> Result<Option<Vec<CrateOwner>>, SlopsquatError> {
let url = format!("https://crates.io/api/v1/crates/{crate_name}/owners");
cached_get(&self.state, "owners", crate_name, &url, |body| {
let parsed: RestOwnersResponse = parse_json_body(body)?;
Ok(parsed.users)
})
}
}
#[cfg(not(feature = "network"))]
pub struct RestOwnersClient {}
#[cfg(not(feature = "network"))]
impl RestOwnersClient {
pub fn new(_cache_root: PathBuf) -> Self {
Self {}
}
}
#[cfg(not(feature = "network"))]
impl CratesIoOwners for RestOwnersClient {
fn owners(&self, _crate_name: &str) -> Result<Option<Vec<CrateOwner>>, SlopsquatError> {
Err(SlopsquatError::Connection(Box::new(std::io::Error::other(
"cargo-judge was compiled without the 'network' feature",
))))
}
}
#[derive(Default)]
pub struct FixtureIndex {
crates: HashMap<String, Vec<IndexVersion>>,
forced_error: Option<String>,
forced_connection_error: bool,
}
impl FixtureIndex {
pub fn new() -> Self {
Self::default()
}
pub fn with_crate(mut self, name: &str, versions: Vec<IndexVersion>) -> Self {
self.crates.insert(name.to_string(), versions);
self
}
pub fn with_error(mut self, message: &str) -> Self {
self.forced_error = Some(message.to_string());
self
}
pub fn with_connection_error(mut self, message: &str) -> Self {
self.forced_error = Some(message.to_string());
self.forced_connection_error = true;
self
}
}
impl CratesIoIndex for FixtureIndex {
fn lookup(&self, crate_name: &str) -> Result<Option<IndexEntry>, SlopsquatError> {
if let Some(message) = &self.forced_error {
return Err(if self.forced_connection_error {
SlopsquatError::Connection(message.clone().into())
} else {
SlopsquatError::Other(message.clone().into())
});
}
Ok(self.crates.get(crate_name).map(|versions| IndexEntry {
versions: versions.clone(),
}))
}
}
#[derive(Default)]
pub struct FixtureMetadata {
crates: HashMap<String, CrateMetadata>,
forced_error: Option<String>,
}
impl FixtureMetadata {
pub fn new() -> Self {
Self::default()
}
pub fn with_crate(mut self, name: &str, metadata: CrateMetadata) -> Self {
self.crates.insert(name.to_string(), metadata);
self
}
pub fn with_error(mut self, message: &str) -> Self {
self.forced_error = Some(message.to_string());
self
}
}
fn fixture_lookup<T: Clone>(
crates: &HashMap<String, T>,
forced_error: &Option<String>,
crate_name: &str,
) -> Result<Option<T>, SlopsquatError> {
if let Some(message) = forced_error {
return Err(SlopsquatError::Other(message.clone().into()));
}
Ok(crates.get(crate_name).cloned())
}
impl CratesIoMetadata for FixtureMetadata {
fn metadata(&self, crate_name: &str) -> Result<Option<CrateMetadata>, SlopsquatError> {
fixture_lookup(&self.crates, &self.forced_error, crate_name)
}
}
#[derive(Default)]
pub struct FixtureOwners {
crates: HashMap<String, Vec<CrateOwner>>,
forced_error: Option<String>,
}
impl FixtureOwners {
pub fn new() -> Self {
Self::default()
}
pub fn with_crate(mut self, name: &str, owners: Vec<CrateOwner>) -> Self {
self.crates.insert(name.to_string(), owners);
self
}
pub fn with_error(mut self, message: &str) -> Self {
self.forced_error = Some(message.to_string());
self
}
}
impl CratesIoOwners for FixtureOwners {
fn owners(&self, crate_name: &str) -> Result<Option<Vec<CrateOwner>>, SlopsquatError> {
fixture_lookup(&self.crates, &self.forced_error, crate_name)
}
}
#[derive(Debug, Default)]
pub struct SlopsquatNetworkReport {
pub findings: Vec<Finding>,
pub errors: Vec<String>,
}
fn record_lookup_error(
report: &mut SlopsquatNetworkReport,
connection_error_reported: &mut bool,
err: SlopsquatError,
item_name: &str,
unreachable_message: &str,
lookup_label: &str,
) {
match err {
SlopsquatError::CircuitOpen => {}
SlopsquatError::Connection(msg) => {
if !*connection_error_reported {
report.errors.push(format!("{unreachable_message}: {msg}"));
*connection_error_reported = true;
}
}
SlopsquatError::Other(msg) => {
report
.errors
.push(format!("{item_name}: {lookup_label} lookup failed: {msg}"));
}
}
}
fn analyze_dependencies_via_lookup<T>(
workspace: &Workspace,
lookup: impl Fn(&str) -> Result<T, SlopsquatError>,
unreachable_message: &str,
lookup_label: &str,
mut on_ok: impl FnMut(&mut Vec<Finding>, &CrateInfo, &DeclaredDependency, T),
) -> SlopsquatNetworkReport {
let mut report = SlopsquatNetworkReport::default();
let mut connection_error_reported = false;
for krate in &workspace.crates {
for dep in &krate.dependencies {
match lookup(&dep.name) {
Ok(result) => on_ok(&mut report.findings, krate, dep, result),
Err(err) => record_lookup_error(
&mut report,
&mut connection_error_reported,
err,
&dep.name,
unreachable_message,
lookup_label,
),
}
}
}
report
}
pub fn analyze_phantom_dependencies(
workspace: &Workspace,
index: &dyn CratesIoIndex,
) -> SlopsquatNetworkReport {
analyze_dependencies_via_lookup(
workspace,
|name| index.lookup(name),
"crates.io sparse index unreachable, skipping remaining \
phantom-crate/phantom-version checks",
"crates.io",
|findings, krate, dep, entry| match entry {
None => findings.push(phantom_crate_finding(krate, dep)),
Some(entry) => {
if let Some(finding) = phantom_version_finding(krate, dep, &entry) {
findings.push(finding);
}
}
},
)
}
fn phantom_crate_finding(krate: &CrateInfo, dep: &DeclaredDependency) -> Finding {
Finding::new(
format!("{PHANTOM_CRATE_RULE}:{}:{}", krate.name, dep.name),
PHANTOM_CRATE_RULE,
Severity::Fail,
crate::rules::deps::dep_location(krate, dep.name.clone()),
EvidenceClass::ExternalMeasurement,
Origin::Code,
Some(serde_json::json!({
"lookup": "sparse-index",
"result": "not_found",
})),
)
}
fn phantom_version_finding(
krate: &CrateInfo,
dep: &DeclaredDependency,
entry: &IndexEntry,
) -> Option<Finding> {
let Ok(req) = VersionReq::parse(&dep.version_req) else {
return None;
};
let published: Vec<&IndexVersion> = entry.versions.iter().filter(|v| !v.yanked).collect();
let satisfied = published
.iter()
.any(|v| Version::parse(&v.vers).is_ok_and(|version| req.matches(&version)));
if satisfied {
return None;
}
Some(Finding::new(
format!("{PHANTOM_VERSION_RULE}:{}:{}", krate.name, dep.name),
PHANTOM_VERSION_RULE,
Severity::Fail,
crate::rules::deps::dep_location(krate, dep.name.clone()),
EvidenceClass::ExternalMeasurement,
Origin::Code,
Some(serde_json::json!({
"requirement": dep.version_req,
"nearest_published_versions": nearest_versions(&published, 3),
})),
))
}
fn nearest_versions(published: &[&IndexVersion], limit: usize) -> Vec<String> {
let mut parsed: Vec<(Version, &str)> = published
.iter()
.filter_map(|v| {
Version::parse(&v.vers)
.ok()
.map(|version| (version, v.vers.as_str()))
})
.collect();
parsed.sort_by(|a, b| b.0.cmp(&a.0));
parsed
.into_iter()
.take(limit)
.map(|(_, raw)| raw.to_string())
.collect()
}
#[derive(Debug, Clone, Deserialize)]
pub struct SlopsquatConfig {
#[serde(default = "SlopsquatConfig::default_min_downloads")]
pub min_downloads: u64,
}
impl SlopsquatConfig {
fn default_min_downloads() -> u64 {
DEFAULT_MIN_DOWNLOADS
}
}
impl Default for SlopsquatConfig {
fn default() -> Self {
Self {
min_downloads: Self::default_min_downloads(),
}
}
}
const DEFAULT_MIN_DOWNLOADS: u64 = 1000;
const FRESH_AGE_DAYS: i64 = 90;
pub fn analyze_fresh_low_reputation(
workspace: &Workspace,
metadata_source: &dyn CratesIoMetadata,
config: &SlopsquatConfig,
) -> SlopsquatNetworkReport {
analyze_dependencies_via_lookup(
workspace,
|name| metadata_source.metadata(name),
"crates.io API unreachable, skipping remaining fresh-low-reputation-dep checks",
"crates.io metadata",
|findings, krate, dep, metadata| {
let Some(metadata) = metadata else {
return;
};
if is_fresh_low_reputation(&metadata, config) {
findings.push(fresh_low_reputation_finding(krate, dep, &metadata));
}
},
)
}
fn is_fresh_low_reputation(metadata: &CrateMetadata, config: &SlopsquatConfig) -> bool {
let Some(created_unix) = parse_rfc3339_to_unix_seconds(&metadata.created_at) else {
return false;
};
let age_days = (now_unix_secs() as i64 - created_unix) / 86_400;
let is_fresh = age_days < FRESH_AGE_DAYS;
let is_low_downloads = metadata.downloads < config.min_downloads;
let has_no_repo = metadata
.repository
.as_ref()
.is_none_or(|repo| repo.trim().is_empty());
is_fresh && is_low_downloads && has_no_repo
}
fn fresh_low_reputation_finding(
krate: &CrateInfo,
dep: &DeclaredDependency,
metadata: &CrateMetadata,
) -> Finding {
Finding::new(
format!(
"{FRESH_LOW_REPUTATION_DEP_RULE}:{}:{}",
krate.name, dep.name
),
FRESH_LOW_REPUTATION_DEP_RULE,
Severity::Warn,
crate::rules::deps::dep_location(krate, dep.name.clone()),
EvidenceClass::ExternalMeasurement,
Origin::Code,
Some(serde_json::json!({
"created_at": metadata.created_at,
"downloads": metadata.downloads,
"repository": metadata.repository,
})),
)
}
fn parse_rfc3339_to_unix_seconds(s: &str) -> Option<i64> {
if s.len() < 19 {
return None;
}
let year: i64 = s.get(0..4)?.parse().ok()?;
let month: i64 = s.get(5..7)?.parse().ok()?;
let day: i64 = s.get(8..10)?.parse().ok()?;
let hour: i64 = s.get(11..13)?.parse().ok()?;
let minute: i64 = s.get(14..16)?.parse().ok()?;
let second: i64 = s.get(17..19)?.parse().ok()?;
let days = days_from_civil(year, month, day);
Some(days * 86_400 + hour * 3_600 + minute * 60 + second)
}
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
pub fn analyze_yanked_dependencies(
workspace: &Workspace,
index: &dyn CratesIoIndex,
) -> SlopsquatNetworkReport {
let mut report = SlopsquatNetworkReport::default();
let manifest_path = workspace.root.join("Cargo.toml");
let metadata = match MetadataCommand::new().manifest_path(&manifest_path).exec() {
Ok(metadata) => metadata,
Err(err) => {
report
.errors
.push(format!("failed to resolve dependency graph: {err}"));
return report;
}
};
let member_ids: HashSet<&cargo_metadata::PackageId> =
metadata.workspace_members.iter().collect();
let mut connection_error_reported = false;
for package in metadata
.packages
.iter()
.filter(|package| !member_ids.contains(&package.id))
{
match index.lookup(&package.name) {
Ok(Some(entry)) => {
let resolved_version = package.version.to_string();
let is_yanked = entry
.versions
.iter()
.any(|v| v.vers == resolved_version && v.yanked);
if is_yanked {
report.findings.push(yanked_dependency_finding(
&manifest_path,
&package.name,
&resolved_version,
));
}
}
Ok(None) => {}
Err(err) => record_lookup_error(
&mut report,
&mut connection_error_reported,
err,
&package.name,
"crates.io sparse index unreachable, skipping remaining yanked-dependency checks",
"crates.io",
),
}
}
report
.findings
.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str()));
report
}
fn yanked_dependency_finding(
manifest_path: &Path,
crate_name: &str,
resolved_version: &str,
) -> Finding {
Finding::new(
format!("{YANKED_DEPENDENCY_RULE}:{crate_name}:{resolved_version}"),
YANKED_DEPENDENCY_RULE,
Severity::Warn,
Location {
file: manifest_path.to_path_buf(),
line: OneBasedLine::FIRST,
item_path: crate_name.to_string(),
},
EvidenceClass::ExternalMeasurement,
Origin::Code,
Some(serde_json::json!({
"lookup": "sparse-index",
"resolved_version": resolved_version,
})),
)
}
pub fn analyze_single_maintainer_dependencies(
workspace: &Workspace,
owners: &dyn CratesIoOwners,
) -> SlopsquatNetworkReport {
analyze_dependencies_via_lookup(
workspace,
|name| owners.owners(name),
"crates.io owners endpoint unreachable, skipping remaining dep-single-maintainer checks",
"crates.io owners",
|findings, krate, dep, owner_list| {
let Some(owner_list) = owner_list else {
return;
};
if owner_list.len() < MIN_MAINTAINER_COUNT {
findings.push(single_maintainer_finding(krate, dep, &owner_list));
}
},
)
}
fn single_maintainer_finding(
krate: &CrateInfo,
dep: &DeclaredDependency,
owners: &[CrateOwner],
) -> Finding {
Finding::new(
format!("{DEP_SINGLE_MAINTAINER_RULE}:{}:{}", krate.name, dep.name),
DEP_SINGLE_MAINTAINER_RULE,
Severity::Warn,
crate::rules::deps::dep_location(krate, dep.name.clone()),
EvidenceClass::ExternalMeasurement,
Origin::Code,
Some(serde_json::json!({
"owner_count": owners.len(),
"owners": owners.iter().map(|o| o.login.clone()).collect::<Vec<_>>(),
})),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::TempDir;
fn write_manifest(dir: &TempDir, deps: &[(&str, &str)]) -> PathBuf {
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
let dep_lines: String = deps
.iter()
.map(|(name, req)| format!("{name} = \"{req}\"\n"))
.collect();
std::fs::write(
dir.join("Cargo.toml"),
format!(
r#"
[package]
name = "fixture"
version = "0.1.0"
edition = "2021"
[dependencies]
{dep_lines}
"#
),
)
.unwrap();
dir.join("Cargo.toml")
}
fn write_manifest_with_dep_line(dir: &TempDir, dep_line: &str) -> PathBuf {
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
std::fs::write(
dir.join("Cargo.toml"),
format!(
"[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n{dep_line}\n"
),
)
.unwrap();
dir.join("Cargo.toml")
}
fn parse_dep_line(dep_line: &str) -> (&str, &str) {
let (name, rest) = dep_line
.split_once('=')
.expect("dep line has `name = \"req\"` shape");
(name.trim(), rest.trim().trim_matches('"'))
}
#[test]
fn a_near_miss_name_is_flagged() {
let dir = TempDir::new("slopsquat-collision-near-miss");
let manifest = write_manifest(&dir, &[("reqwests", "1.0")]);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let findings = analyze_name_collision(&workspace);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].rule, NAME_COLLISION_RISK_RULE);
assert_eq!(findings[0].location.item_path, "reqwests");
let evidence = findings[0].evidence.as_ref().unwrap();
assert_eq!(evidence["nearest_popular_crate"], "reqwest");
assert_eq!(evidence["edit_distance"], 1);
}
#[test]
fn a_same_family_pair_is_not_flagged() {
let dir = TempDir::new("slopsquat-collision-family");
let manifest = write_manifest(&dir, &[("tokio_util", "1.0")]);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let findings = analyze_name_collision(&workspace);
assert!(findings.is_empty());
}
#[test]
fn short_names_are_never_flagged() {
let dir = TempDir::new("slopsquat-collision-short");
let manifest = write_manifest(&dir, &[("rand", "1.0")]);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let findings = analyze_name_collision(&workspace);
assert!(findings.is_empty());
}
#[test]
fn an_exact_match_to_a_popular_name_is_not_flagged() {
let dir = TempDir::new("slopsquat-collision-exact");
let manifest = write_manifest(&dir, &[("reqwest", "1.0")]);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let findings = analyze_name_collision(&workspace);
assert!(findings.is_empty());
}
#[test]
fn levenshtein_matches_known_distances() {
assert_eq!(levenshtein("toko", "tokio"), 1);
assert_eq!(levenshtein("reqwest", "reqwest"), 0);
assert_eq!(levenshtein("kitten", "sitting"), 3);
}
#[test]
fn a_structurally_similar_but_plausibly_legitimate_successor_name_is_still_flagged() {
let dir = TempDir::new("slopsquat-collision-legit-successor");
let manifest = write_manifest(&dir, &[("anyhow2", "1.0")]);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let findings = analyze_name_collision(&workspace);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].rule, NAME_COLLISION_RISK_RULE);
assert_eq!(findings[0].severity, Severity::Warn);
assert_eq!(findings[0].evidence_class, EvidenceClass::Heuristic);
let evidence = findings[0].evidence.as_ref().unwrap();
assert_eq!(evidence["nearest_popular_crate"], "anyhow");
assert_eq!(evidence["edit_distance"], 1);
}
#[test]
fn name_collision_risk_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(NAME_COLLISION_RISK_RULE)
.expect("name-collision-risk has a registry entry")
.example
.expect("name-collision-risk has a curated example")
.before;
let dir = TempDir::new("slopsquat-collision-registry-example");
let manifest = write_manifest_with_dep_line(&dir, example);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let findings = analyze_name_collision(&workspace);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].rule, NAME_COLLISION_RISK_RULE);
}
#[test]
fn an_existing_crate_with_a_satisfied_requirement_has_no_finding() {
let dir = TempDir::new("slopsquat-phantom-ok");
let manifest = write_manifest(&dir, &[("widgetcrate", "1.0")]);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let index = FixtureIndex::new().with_crate(
"widgetcrate",
vec![IndexVersion {
vers: "1.2.0".to_string(),
yanked: false,
}],
);
let report = analyze_phantom_dependencies(&workspace, &index);
assert!(report.findings.is_empty());
assert!(report.errors.is_empty());
}
#[test]
fn a_nonexistent_crate_name_is_a_phantom_crate_finding() {
let dir = TempDir::new("slopsquat-phantom-crate");
let manifest = write_manifest(&dir, &[("totally-hallucinated-crate", "1.0")]);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let index = FixtureIndex::new();
let report = analyze_phantom_dependencies(&workspace, &index);
assert_eq!(report.findings.len(), 1);
assert_eq!(report.findings[0].rule, PHANTOM_CRATE_RULE);
assert_eq!(report.findings[0].severity, Severity::Fail);
}
#[test]
fn phantom_crate_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(PHANTOM_CRATE_RULE)
.expect("phantom-crate has a registry entry")
.example
.expect("phantom-crate has a curated example")
.before;
let dir = TempDir::new("slopsquat-phantom-crate-registry-example");
let manifest = write_manifest_with_dep_line(&dir, example);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let index = FixtureIndex::new();
let report = analyze_phantom_dependencies(&workspace, &index);
assert_eq!(report.findings.len(), 1);
assert_eq!(report.findings[0].rule, PHANTOM_CRATE_RULE);
}
#[test]
fn a_requirement_with_no_satisfying_published_version_is_a_phantom_version_finding() {
let dir = TempDir::new("slopsquat-phantom-version");
let manifest = write_manifest(&dir, &[("widgetcrate", "99.0")]);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let index = FixtureIndex::new().with_crate(
"widgetcrate",
vec![IndexVersion {
vers: "1.2.0".to_string(),
yanked: false,
}],
);
let report = analyze_phantom_dependencies(&workspace, &index);
assert_eq!(report.findings.len(), 1);
assert_eq!(report.findings[0].rule, PHANTOM_VERSION_RULE);
assert_eq!(report.findings[0].severity, Severity::Fail);
}
#[test]
fn phantom_version_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(PHANTOM_VERSION_RULE)
.expect("phantom-version has a registry entry")
.example
.expect("phantom-version has a curated example")
.before;
let (name, _req) = parse_dep_line(example);
let dir = TempDir::new("slopsquat-phantom-version-registry-example");
let manifest = write_manifest_with_dep_line(&dir, example);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let index = FixtureIndex::new().with_crate(
name,
vec![IndexVersion {
vers: "1.2.0".to_string(),
yanked: false,
}],
);
let report = analyze_phantom_dependencies(&workspace, &index);
assert_eq!(report.findings.len(), 1);
assert_eq!(report.findings[0].rule, PHANTOM_VERSION_RULE);
}
#[test]
fn an_index_lookup_error_surfaces_as_an_error_with_no_findings_and_no_panic() {
let dir = TempDir::new("slopsquat-phantom-error");
let manifest = write_manifest(&dir, &[("widgetcrate", "1.0")]);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let index = FixtureIndex::new().with_error("simulated failure");
let report = analyze_phantom_dependencies(&workspace, &index);
assert!(report.findings.is_empty());
assert_eq!(report.errors.len(), 1);
}
#[test]
fn a_simulated_network_outage_never_produces_a_phantom_crate_finding() {
let dir = TempDir::new("slopsquat-phantom-network-outage");
let manifest = write_manifest(&dir, &[("widgetcrate", "1.0"), ("othercrate", "1.0")]);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let index = FixtureIndex::new().with_connection_error("connection timed out");
let report = analyze_phantom_dependencies(&workspace, &index);
assert!(
report.findings.is_empty(),
"a network outage must never be reported as a phantom-crate/phantom-version finding: {:?}",
report.findings
);
assert_eq!(report.errors.len(), 1);
assert!(report.errors[0].contains("unreachable"));
}
#[test]
fn phantom_crate_cannot_distinguish_a_permanently_nonexistent_name_from_one_registered_a_moment_after_the_snapshot()
{
let dir = TempDir::new("slopsquat-phantom-snapshot-race");
let manifest = write_manifest(
&dir,
&[
("truly-never-registered-crate-xyz", "1.0"),
("registered-a-moment-after-the-lookup-ran", "1.0"),
],
);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let index = FixtureIndex::new();
let report = analyze_phantom_dependencies(&workspace, &index);
assert_eq!(report.findings.len(), 2, "{:?}", report.findings);
let by_name: HashMap<&str, &Finding> = report
.findings
.iter()
.map(|f| (f.location.item_path.as_str(), f))
.collect();
for finding in by_name.values() {
assert_eq!(finding.rule, PHANTOM_CRATE_RULE);
assert_eq!(
finding.evidence.as_ref().unwrap()["result"],
"not_found",
"nothing in the evidence distinguishes the two stories: {finding:?}"
);
}
}
#[test]
fn a_fresh_low_download_repo_less_crate_is_flagged() {
let dir = TempDir::new("slopsquat-fresh-flagged");
let manifest = write_manifest(&dir, &[("brandnewcrate", "1.0")]);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let recent = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
- 10 * 86_400; let created_at = unix_seconds_to_rfc3339(recent as i64);
let metadata_source = FixtureMetadata::new().with_crate(
"brandnewcrate",
CrateMetadata {
created_at,
downloads: 5,
repository: None,
},
);
let config = SlopsquatConfig::default();
let report = analyze_fresh_low_reputation(&workspace, &metadata_source, &config);
assert_eq!(report.findings.len(), 1);
assert_eq!(report.findings[0].rule, FRESH_LOW_REPUTATION_DEP_RULE);
}
#[test]
fn fresh_low_reputation_dep_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(FRESH_LOW_REPUTATION_DEP_RULE)
.expect("fresh-low-reputation-dep has a registry entry")
.example
.expect("fresh-low-reputation-dep has a curated example")
.before;
let (name, _req) = parse_dep_line(example);
let dir = TempDir::new("slopsquat-fresh-registry-example");
let manifest = write_manifest_with_dep_line(&dir, example);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let recent = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
- 10 * 86_400; let created_at = unix_seconds_to_rfc3339(recent as i64);
let metadata_source = FixtureMetadata::new().with_crate(
name,
CrateMetadata {
created_at,
downloads: 5,
repository: None,
},
);
let config = SlopsquatConfig::default();
let report = analyze_fresh_low_reputation(&workspace, &metadata_source, &config);
assert_eq!(report.findings.len(), 1);
assert_eq!(report.findings[0].rule, FRESH_LOW_REPUTATION_DEP_RULE);
}
#[test]
fn an_old_well_downloaded_crate_with_a_repo_is_not_flagged() {
let dir = TempDir::new("slopsquat-fresh-ok");
let manifest = write_manifest(&dir, &[("establishedcrate", "1.0")]);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let metadata_source = FixtureMetadata::new().with_crate(
"establishedcrate",
CrateMetadata {
created_at: "2015-01-01T00:00:00.000000Z".to_string(),
downloads: 50_000_000,
repository: Some("https://github.com/example/establishedcrate".to_string()),
},
);
let config = SlopsquatConfig::default();
let report = analyze_fresh_low_reputation(&workspace, &metadata_source, &config);
assert!(report.findings.is_empty());
}
#[test]
fn min_downloads_is_configurable() {
let dir = TempDir::new("slopsquat-fresh-configurable");
let manifest = write_manifest(&dir, &[("midtiercrate", "1.0")]);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let recent = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
- 5 * 86_400;
let created_at = unix_seconds_to_rfc3339(recent as i64);
let metadata_source = FixtureMetadata::new().with_crate(
"midtiercrate",
CrateMetadata {
created_at,
downloads: 5_000,
repository: None,
},
);
let default_config = SlopsquatConfig::default();
let report = analyze_fresh_low_reputation(&workspace, &metadata_source, &default_config);
assert!(report.findings.is_empty());
let strict_config = SlopsquatConfig {
min_downloads: 10_000,
};
let report = analyze_fresh_low_reputation(&workspace, &metadata_source, &strict_config);
assert_eq!(report.findings.len(), 1);
}
#[test]
fn a_metadata_lookup_error_surfaces_as_an_error_with_no_findings_and_no_panic() {
let dir = TempDir::new("slopsquat-fresh-error");
let manifest = write_manifest(&dir, &[("brandnewcrate", "1.0")]);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let metadata_source = FixtureMetadata::new().with_error("unparsable response body");
let config = SlopsquatConfig::default();
let report = analyze_fresh_low_reputation(&workspace, &metadata_source, &config);
assert!(report.findings.is_empty());
assert_eq!(report.errors.len(), 1);
}
fn write_vendored_crate(dir: &TempDir, name: &str, version: &str) {
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(
dir.join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\nedition = \"2021\"\n"),
)
.unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn noop() {}\n").unwrap();
}
fn write_manifest_with_vendored_dep(
dir: &TempDir,
dep_name: &str,
vendor: &TempDir,
) -> PathBuf {
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
std::fs::write(
dir.join("Cargo.toml"),
format!(
"[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n{dep_name} = {{ path = {:?} }}\n",
vendor.to_path_buf()
),
)
.unwrap();
dir.join("Cargo.toml")
}
#[test]
fn yanked_dependency_fires_for_a_resolved_packages_yanked_version() {
let vendor = TempDir::new("slopsquat-yanked-vendor");
write_vendored_crate(&vendor, "vendored-dep", "1.2.3");
let dir = TempDir::new("slopsquat-yanked-fixture");
let manifest = write_manifest_with_vendored_dep(&dir, "vendored-dep", &vendor);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let index = FixtureIndex::new().with_crate(
"vendored-dep",
vec![IndexVersion {
vers: "1.2.3".to_string(),
yanked: true,
}],
);
let report = analyze_yanked_dependencies(&workspace, &index);
assert!(
report.errors.is_empty(),
"unexpected errors: {:?}",
report.errors
);
assert_eq!(report.findings.len(), 1);
assert_eq!(report.findings[0].rule, YANKED_DEPENDENCY_RULE);
assert_eq!(report.findings[0].location.item_path, "vendored-dep");
assert_eq!(
report.findings[0].evidence.as_ref().unwrap()["resolved_version"],
"1.2.3"
);
}
#[test]
fn yanked_dependency_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(YANKED_DEPENDENCY_RULE)
.expect("yanked-dependency has a registry entry")
.example
.expect("yanked-dependency has a curated example")
.before;
let (name, version) = parse_dep_line(example);
let vendor = TempDir::new("slopsquat-yanked-registry-example-vendor");
write_vendored_crate(&vendor, name, version);
let dir = TempDir::new("slopsquat-yanked-registry-example-fixture");
let manifest = write_manifest_with_vendored_dep(&dir, name, &vendor);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let index = FixtureIndex::new().with_crate(
name,
vec![IndexVersion {
vers: version.to_string(),
yanked: true,
}],
);
let report = analyze_yanked_dependencies(&workspace, &index);
assert_eq!(report.findings.len(), 1);
assert_eq!(report.findings[0].rule, YANKED_DEPENDENCY_RULE);
}
#[test]
fn yanked_dependency_does_not_fire_for_a_non_yanked_resolved_version() {
let vendor = TempDir::new("slopsquat-not-yanked-vendor");
write_vendored_crate(&vendor, "vendored-dep", "1.2.3");
let dir = TempDir::new("slopsquat-not-yanked-fixture");
let manifest = write_manifest_with_vendored_dep(&dir, "vendored-dep", &vendor);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let index = FixtureIndex::new().with_crate(
"vendored-dep",
vec![IndexVersion {
vers: "1.2.3".to_string(),
yanked: false,
}],
);
let report = analyze_yanked_dependencies(&workspace, &index);
assert!(report.findings.is_empty());
}
#[test]
fn yanked_dependency_never_checks_the_workspace_member_itself() {
let dir = TempDir::new("slopsquat-yanked-no-external-deps");
let manifest = write_manifest(&dir, &[]);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let index = FixtureIndex::new().with_crate(
"fixture",
vec![IndexVersion {
vers: "0.1.0".to_string(),
yanked: true,
}],
);
let report = analyze_yanked_dependencies(&workspace, &index);
assert!(report.findings.is_empty());
}
#[test]
fn yanked_dependency_reports_a_connection_error_without_a_finding() {
let vendor = TempDir::new("slopsquat-yanked-conn-err-vendor");
write_vendored_crate(&vendor, "vendored-dep", "1.2.3");
let dir = TempDir::new("slopsquat-yanked-conn-err-fixture");
let manifest = write_manifest_with_vendored_dep(&dir, "vendored-dep", &vendor);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let index = FixtureIndex::new().with_connection_error("simulated outage");
let report = analyze_yanked_dependencies(&workspace, &index);
assert!(report.findings.is_empty());
assert_eq!(report.errors.len(), 1);
assert!(report.errors[0].contains("unreachable"));
}
#[test]
fn yanked_dependency_reports_a_metadata_resolve_failure_without_a_finding() {
let dir = TempDir::new("slopsquat-yanked-resolve-failure");
let workspace = Workspace {
root: dir.to_path_buf(),
crates: Vec::new(),
};
let index = FixtureIndex::new();
let report = analyze_yanked_dependencies(&workspace, &index);
assert!(report.findings.is_empty());
assert_eq!(report.errors.len(), 1);
assert!(report.errors[0].contains("failed to resolve dependency graph"));
}
#[test]
fn yanked_dependency_cannot_distinguish_a_permanent_yank_from_one_reverted_a_moment_after_the_snapshot()
{
let vendor_a = TempDir::new("slopsquat-yanked-snapshot-race-vendor-a");
write_vendored_crate(&vendor_a, "permanently-yanked-dep", "1.2.3");
let vendor_b = TempDir::new("slopsquat-yanked-snapshot-race-vendor-b");
write_vendored_crate(&vendor_b, "reverted-a-moment-later-dep", "1.2.3");
let dir = TempDir::new("slopsquat-yanked-snapshot-race-fixture");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
std::fs::write(
dir.join("Cargo.toml"),
format!(
"[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\npermanently-yanked-dep = {{ path = {:?} }}\nreverted-a-moment-later-dep = {{ path = {:?} }}\n",
vendor_a.to_path_buf(),
vendor_b.to_path_buf()
),
)
.unwrap();
let manifest = dir.join("Cargo.toml");
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let index = FixtureIndex::new()
.with_crate(
"permanently-yanked-dep",
vec![IndexVersion {
vers: "1.2.3".to_string(),
yanked: true,
}],
)
.with_crate(
"reverted-a-moment-later-dep",
vec![IndexVersion {
vers: "1.2.3".to_string(),
yanked: true,
}],
);
let report = analyze_yanked_dependencies(&workspace, &index);
assert_eq!(report.findings.len(), 2, "{:?}", report.findings);
for finding in &report.findings {
assert_eq!(finding.rule, YANKED_DEPENDENCY_RULE);
assert_eq!(
finding.evidence.as_ref().unwrap()["resolved_version"],
"1.2.3",
"nothing in the evidence distinguishes the two stories: {finding:?}"
);
}
}
#[test]
fn a_single_owner_crate_is_flagged() {
let dir = TempDir::new("slopsquat-single-maintainer-flagged");
let manifest = write_manifest(&dir, &[("soloproject", "1.0")]);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let owners_source = FixtureOwners::new().with_crate(
"soloproject",
vec![CrateOwner {
login: "solo-dev".to_string(),
}],
);
let report = analyze_single_maintainer_dependencies(&workspace, &owners_source);
assert_eq!(report.findings.len(), 1);
assert_eq!(report.findings[0].rule, DEP_SINGLE_MAINTAINER_RULE);
assert_eq!(report.findings[0].location.item_path, "soloproject");
let evidence = report.findings[0].evidence.as_ref().unwrap();
assert_eq!(evidence["owner_count"], 1);
assert_eq!(evidence["owners"], serde_json::json!(["solo-dev"]));
}
#[test]
fn dep_single_maintainer_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(DEP_SINGLE_MAINTAINER_RULE)
.expect("dep-single-maintainer has a registry entry")
.example
.expect("dep-single-maintainer has a curated example")
.before;
let (name, _req) = parse_dep_line(example);
let dir = TempDir::new("slopsquat-single-maintainer-registry-example");
let manifest = write_manifest_with_dep_line(&dir, example);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let owners_source = FixtureOwners::new().with_crate(
name,
vec![CrateOwner {
login: "solo-maintainer".to_string(),
}],
);
let report = analyze_single_maintainer_dependencies(&workspace, &owners_source);
assert_eq!(report.findings.len(), 1);
assert_eq!(report.findings[0].rule, DEP_SINGLE_MAINTAINER_RULE);
}
#[test]
fn a_multi_owner_crate_is_not_flagged() {
let dir = TempDir::new("slopsquat-single-maintainer-ok");
let manifest = write_manifest(&dir, &[("teamproject", "1.0")]);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let owners_source = FixtureOwners::new().with_crate(
"teamproject",
vec![
CrateOwner {
login: "dev-one".to_string(),
},
CrateOwner {
login: "dev-two".to_string(),
},
],
);
let report = analyze_single_maintainer_dependencies(&workspace, &owners_source);
assert!(report.findings.is_empty());
}
#[test]
fn a_metadata_lookup_error_surfaces_as_an_error_with_no_findings_and_no_panic_for_owners() {
let dir = TempDir::new("slopsquat-single-maintainer-error");
let manifest = write_manifest(&dir, &[("brandnewcrate", "1.0")]);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let owners_source = FixtureOwners::new().with_error("unparsable response body");
let report = analyze_single_maintainer_dependencies(&workspace, &owners_source);
assert!(report.findings.is_empty());
assert_eq!(report.errors.len(), 1);
}
#[test]
fn a_nonexistent_crate_produces_no_single_maintainer_finding() {
let dir = TempDir::new("slopsquat-single-maintainer-nonexistent");
let manifest = write_manifest(&dir, &[("doesnotexistatall", "1.0")]);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let owners_source = FixtureOwners::new();
let report = analyze_single_maintainer_dependencies(&workspace, &owners_source);
assert!(report.findings.is_empty());
assert!(report.errors.is_empty());
}
#[test]
fn dep_single_maintainer_cannot_distinguish_two_active_owners_from_two_dormant_ones() {
let dir = TempDir::new("slopsquat-single-maintainer-activity-blind");
let manifest = write_manifest(
&dir,
&[
("actively-maintained-project", "1.0"),
("abandoned-in-all-but-name-project", "1.0"),
],
);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let owners_source = FixtureOwners::new()
.with_crate(
"actively-maintained-project",
vec![
CrateOwner {
login: "active-dev-one".to_string(),
},
CrateOwner {
login: "active-dev-two".to_string(),
},
],
)
.with_crate(
"abandoned-in-all-but-name-project",
vec![
CrateOwner {
login: "dormant-dev-one".to_string(),
},
CrateOwner {
login: "dormant-dev-two".to_string(),
},
],
);
let report = analyze_single_maintainer_dependencies(&workspace, &owners_source);
assert!(
report.findings.is_empty(),
"neither the genuinely healthy crate nor the effectively-abandoned one is flagged \
-- a raw owner count of 2 can't tell them apart: {:?}",
report.findings
);
}
#[test]
fn days_from_civil_matches_known_epoch_offsets() {
assert_eq!(days_from_civil(1970, 1, 1), 0);
assert_eq!(days_from_civil(2014, 11, 14), 16_388);
}
fn unix_seconds_to_rfc3339(unix_seconds: i64) -> String {
let days = unix_seconds.div_euclid(86_400);
let (year, month, day) = civil_from_days(days);
format!("{year:04}-{month:02}-{day:02}T00:00:00.000000Z")
}
fn civil_from_days(z: i64) -> (i64, i64, i64) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[test]
fn slopsquat_error_source_preserves_the_underlying_error() {
let err = SlopsquatError::Other(Box::new(std::io::Error::other("boom")));
let source = std::error::Error::source(&err).expect("Other must carry a source");
assert!(source.downcast_ref::<std::io::Error>().is_some());
assert_eq!(err.to_string(), "crates.io lookup failed: boom");
assert!(std::error::Error::source(&SlopsquatError::CircuitOpen).is_none());
}
}