use std::collections::HashSet;
use std::path::{Path, PathBuf};
use ra_ap_hir::{ModuleDef, PathResolution, Semantics};
use ra_ap_ide::RootDatabase;
use ra_ap_syntax::ast::{HasModuleItem, HasName, HasVisibility};
use ra_ap_syntax::{AstNode, TextRange, ast};
use serde_json::json;
use crate::deep::{DeepContext, DeepError, FileId};
use crate::finding::{EvidenceClass, Finding, Location, OneBasedLine, Origin, Severity};
use crate::ingest::Workspace;
use crate::rules::api_surface::{self, PubFnCandidate, SEMVER_HAZARD_RULE};
pub const INTERNAL_LEAK_RULE: &str = "internal-leak";
pub const INTERNAL_LEAK_RULE_REVISION: u32 = 1;
pub const RE_EXPORT_CHAIN_RULE: &str = "re-export-chain";
pub const RE_EXPORT_CHAIN_RULE_REVISION: u32 = 1;
const RE_EXPORT_CHAIN_MIN_HOP_COUNT: usize = 2;
const RE_EXPORT_CHAIN_MAX_HOPS: usize = 5;
const LANGUAGE_CRATE_NAMES: [&str; 3] = ["std", "core", "alloc"];
#[derive(Debug)]
pub enum ApiSurfaceDeepError {
Deep(DeepError),
ApiSurface(api_surface::ApiSurfaceError),
}
impl std::fmt::Display for ApiSurfaceDeepError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Deep(err) => write!(f, "{err}"),
Self::ApiSurface(err) => write!(f, "{err}"),
}
}
}
impl std::error::Error for ApiSurfaceDeepError {}
#[derive(Debug, Default)]
pub struct DeepApiSurfaceReport {
pub findings: Vec<Finding>,
pub errors: Vec<ApiSurfaceDeepError>,
pub checked: usize,
}
struct LeakedType {
type_name: String,
defining_crate: String,
}
struct ReExportChain {
exported_path: String,
defining_path: String,
hop_count: usize,
}
struct ModuleFile {
file_id: FileId,
path: PathBuf,
module: ra_ap_hir::Module,
}
fn build_module_index(
workspace: &Workspace,
ctx: &DeepContext,
sema: &Semantics<'_, RootDatabase>,
) -> Result<Vec<ModuleFile>, DeepError> {
let mut index = Vec::new();
for krate in &workspace.crates {
for file in &krate.source_files {
if !file.kind.is_locally_reportable() {
continue;
}
let Some(file_id) = ctx.file_id(&file.path)? else {
continue;
};
let Some(module) = sema.file_to_module_def(file_id) else {
continue;
};
index.push(ModuleFile {
file_id,
path: file.path.clone(),
module,
});
}
}
Ok(index)
}
fn is_plain_pub(vis: Option<ast::Visibility>) -> bool {
vis.is_some_and(|vis| vis.pub_token().is_some() && vis.visibility_inner().is_none())
}
fn leaf_name(path: &ast::Path) -> Option<String> {
Some(path.segment()?.name_ref()?.text().to_string())
}
fn resolve_qualifier_module(
sema: &Semantics<'_, RootDatabase>,
path: &ast::Path,
) -> Option<ra_ap_hir::Module> {
let qualifier = path.qualifier()?;
match sema.resolve_path(&qualifier)? {
PathResolution::Def(ModuleDef::Module(module)) => Some(module),
_ => None,
}
}
enum NamedTopLevelItem {
ReExport(ast::Path),
Original,
}
fn name_matches(name: Option<ast::Name>, target: &str) -> bool {
name.is_some_and(|name| name.text() == target)
}
fn simple_pub_use(use_item: &ast::Use) -> Option<(String, ast::Path)> {
if !is_plain_pub(use_item.visibility()) {
return None;
}
let tree = use_item.use_tree()?;
if !tree.is_simple_path() {
return None;
}
let use_path = tree.path()?;
let introduced = tree
.rename()
.and_then(|rename| rename.name())
.map(|name| name.text().to_string())
.or_else(|| leaf_name(&use_path))?;
Some((introduced, use_path))
}
fn find_named_top_level_item(
source_file: &ast::SourceFile,
name: &str,
) -> Option<NamedTopLevelItem> {
for item in source_file.items() {
match &item {
ast::Item::Use(use_item) => {
if let Some((introduced, use_path)) = simple_pub_use(use_item)
&& introduced == name
{
return Some(NamedTopLevelItem::ReExport(use_path));
}
}
ast::Item::Fn(it) if name_matches(it.name(), name) => {
return Some(NamedTopLevelItem::Original);
}
ast::Item::Struct(it) if name_matches(it.name(), name) => {
return Some(NamedTopLevelItem::Original);
}
ast::Item::Enum(it) if name_matches(it.name(), name) => {
return Some(NamedTopLevelItem::Original);
}
ast::Item::Trait(it) if name_matches(it.name(), name) => {
return Some(NamedTopLevelItem::Original);
}
ast::Item::Const(it) if name_matches(it.name(), name) => {
return Some(NamedTopLevelItem::Original);
}
ast::Item::Static(it) if name_matches(it.name(), name) => {
return Some(NamedTopLevelItem::Original);
}
ast::Item::TypeAlias(it) if name_matches(it.name(), name) => {
return Some(NamedTopLevelItem::Original);
}
ast::Item::Union(it) if name_matches(it.name(), name) => {
return Some(NamedTopLevelItem::Original);
}
_ => {}
}
}
None
}
fn walk_hops(
sema: &Semantics<'_, RootDatabase>,
index: &[ModuleFile],
start_path: &ast::Path,
) -> Option<usize> {
let mut current_path = start_path.clone();
let mut hop_count = 1usize;
loop {
if hop_count >= RE_EXPORT_CHAIN_MAX_HOPS {
return Some(RE_EXPORT_CHAIN_MAX_HOPS);
}
let target_module = resolve_qualifier_module(sema, ¤t_path)?;
let name = leaf_name(¤t_path)?;
let target_file = index.iter().find(|entry| entry.module == target_module)?;
let target_source = sema.parse_guess_edition(target_file.file_id);
match find_named_top_level_item(&target_source, &name) {
Some(NamedTopLevelItem::ReExport(next_path)) => {
hop_count += 1;
current_path = next_path;
}
Some(NamedTopLevelItem::Original) => return Some(hop_count),
None => return None,
}
}
}
fn defining_path_of(def: ModuleDef, db: &RootDatabase) -> Option<String> {
let name = def.name(db)?.as_str().to_string();
let module = def.module(db)?;
let mut segments: Vec<String> = module
.path_to_root(db)
.into_iter()
.rev()
.skip(1)
.filter_map(|module| module.name(db).map(|name| name.as_str().to_string()))
.collect();
segments.push(name);
let mut full = vec![crate_display_name(module.krate(db), db)];
full.append(&mut segments);
Some(full.join("::"))
}
fn line_number(source_text: &str, offset: usize) -> usize {
source_text
.get(..offset.min(source_text.len()))
.map_or(1, |prefix| prefix.matches('\n').count() + 1)
}
fn re_export_chain_finding(file: &Path, line: OneBasedLine, chain: &ReExportChain) -> Finding {
Finding::new(
format!(
"{RE_EXPORT_CHAIN_RULE}:{}:{}",
file.display(),
chain.exported_path
),
RE_EXPORT_CHAIN_RULE,
Severity::Info,
Location {
file: file.to_path_buf(),
line,
item_path: chain.exported_path.clone(),
},
EvidenceClass::Heuristic,
Origin::Code,
Some(json!({
"kind": "re_export_chain",
"exported_path": chain.exported_path,
"defining_path": chain.defining_path,
"hop_count": chain.hop_count,
"capped": chain.hop_count == RE_EXPORT_CHAIN_MAX_HOPS,
})),
)
}
fn re_export_chain_findings(
workspace: &Workspace,
ctx: &DeepContext,
sema: &Semantics<'_, RootDatabase>,
db: &RootDatabase,
) -> Result<Vec<Finding>, DeepError> {
let index = build_module_index(workspace, ctx, sema)?;
let mut findings = Vec::new();
for entry in &index {
let source_file = sema.parse_guess_edition(entry.file_id);
let source_text = source_file.syntax().text().to_string();
let own_crate_name = crate_display_name(entry.module.krate(db), db);
for item in source_file.items() {
let ast::Item::Use(use_item) = &item else {
continue;
};
let Some((introduced, use_path)) = simple_pub_use(use_item) else {
continue;
};
let Some(PathResolution::Def(final_def)) = sema.resolve_path(&use_path) else {
continue;
};
let Some(defining_path) = defining_path_of(final_def, db) else {
continue;
};
let Some(hop_count) = walk_hops(sema, &index, &use_path) else {
continue;
};
if hop_count < RE_EXPORT_CHAIN_MIN_HOP_COUNT {
continue;
}
let chain = ReExportChain {
exported_path: format!("{own_crate_name}::{introduced}"),
defining_path,
hop_count,
};
let offset = u32::from(use_item.syntax().text_range().start()) as usize;
let line =
OneBasedLine::new(line_number(&source_text, offset)).unwrap_or(OneBasedLine::FIRST);
findings.push(re_export_chain_finding(&entry.path, line, &chain));
}
}
Ok(findings)
}
fn is_language_crate(krate: ra_ap_hir::Crate, db: &RootDatabase) -> bool {
krate
.display_name(db)
.is_some_and(|name| LANGUAGE_CRATE_NAMES.contains(&name.to_string().as_str()))
}
fn leaked_type(
ty: &ra_ap_hir::Type<'_>,
db: &RootDatabase,
own_crate: ra_ap_hir::Crate,
) -> Option<LeakedType> {
let stripped = ty.strip_references();
let (adt, args) = stripped.as_adt_with_args()?;
let container_crate = adt.module(db).krate(db);
if !is_language_crate(container_crate, db) {
if container_crate == own_crate {
return None;
}
return Some(LeakedType {
type_name: adt.name(db).as_str().to_string(),
defining_crate: crate_display_name(container_crate, db),
});
}
args.into_iter().flatten().find_map(|arg| {
let inner_adt = arg.as_adt()?;
let inner_crate = inner_adt.module(db).krate(db);
if is_language_crate(inner_crate, db) || inner_crate == own_crate {
return None;
}
Some(LeakedType {
type_name: inner_adt.name(db).as_str().to_string(),
defining_crate: crate_display_name(inner_crate, db),
})
})
}
pub(crate) fn crate_display_name(krate: ra_ap_hir::Crate, db: &RootDatabase) -> String {
krate
.display_name(db)
.map_or_else(|| "?".to_string(), |name| name.to_string())
}
pub(crate) fn resolve_span_node<N: AstNode>(
sema: &Semantics<'_, RootDatabase>,
file_id: FileId,
span: proc_macro2::Span,
) -> Option<N> {
let byte_range = span.byte_range();
let text_range = TextRange::new(
(byte_range.start as u32).into(),
(byte_range.end as u32).into(),
);
let source_file = sema.parse_guess_edition(file_id);
let token = source_file
.syntax()
.token_at_offset(text_range.start())
.find(|token| token.text_range() == text_range)?;
token.parent()?.ancestors().find_map(N::cast)
}
fn resolve_fn_node(
sema: &Semantics<'_, RootDatabase>,
file_id: FileId,
ident_span: proc_macro2::Span,
) -> Option<ast::Fn> {
resolve_span_node(sema, file_id, ident_span)
}
fn checked_types(fn_node: &ast::Fn) -> Vec<(String, ast::Type)> {
let mut types = Vec::new();
if let Some(param_list) = fn_node.param_list() {
for (index, param) in param_list.params().enumerate() {
if let Some(ty) = param.ty() {
types.push((format!("parameter{index}"), ty));
}
}
}
if let Some(ty) = fn_node.ret_type().and_then(|ret_type| ret_type.ty()) {
types.push(("return".to_string(), ty));
}
types
}
fn leak_type_finding(
candidate: &PubFnCandidate,
site: &str,
leak: &LeakedType,
rule_id: &str,
severity: Severity,
kind: &str,
) -> Finding {
Finding {
id: format!(
"{rule_id}:{}:{}:{site}:{}",
candidate.file.display(),
candidate.item_path,
leak.type_name,
)
.into(),
rule: rule_id.into(),
severity,
location: Location {
file: candidate.file.clone(),
line: OneBasedLine::new(candidate.ident_span.start().line)
.expect("proc-macro2 span lines are 1-based"),
item_path: candidate.item_path.clone(),
},
evidence_class: EvidenceClass::BoundedSemantic,
origin: Origin::Code,
evidence: Some(json!({
"kind": kind,
"type_name": leak.type_name,
"defining_crate": leak.defining_crate,
"site": site,
})),
limitations: None,
caused_by: Vec::new(),
causes: Vec::new(),
}
}
fn leak_finding(candidate: &PubFnCandidate, site: &str, leak: &LeakedType) -> Finding {
leak_type_finding(
candidate,
site,
leak,
SEMVER_HAZARD_RULE,
Severity::Info,
"leaked_dependency_type",
)
}
fn resolve_leaks_for_candidate(
sema: &Semantics<'_, RootDatabase>,
db: &RootDatabase,
file_id: FileId,
own_crate: ra_ap_hir::Crate,
candidate: &PubFnCandidate,
) -> Vec<(String, LeakedType)> {
let Some(fn_node) = resolve_fn_node(sema, file_id, candidate.ident_span) else {
return Vec::new();
};
checked_types(&fn_node)
.into_iter()
.filter_map(|(site, ty)| {
let resolved = sema.resolve_type(&ty)?;
let leak = leaked_type(&resolved, db, own_crate)?;
Some((site, leak))
})
.collect()
}
fn leaked_types_for_candidate(
sema: &Semantics<'_, RootDatabase>,
db: &RootDatabase,
file_id: FileId,
own_crate: ra_ap_hir::Crate,
candidate: &PubFnCandidate,
) -> Vec<Finding> {
resolve_leaks_for_candidate(sema, db, file_id, own_crate, candidate)
.iter()
.map(|(site, leak)| leak_finding(candidate, site, leak))
.collect()
}
fn internal_leak_finding(candidate: &PubFnCandidate, site: &str, leak: &LeakedType) -> Finding {
leak_type_finding(
candidate,
site,
leak,
INTERNAL_LEAK_RULE,
Severity::Warn,
"internal_leak",
)
}
pub fn analyze_workspace(
workspace: &Workspace,
internal_crates: &[String],
) -> Result<DeepApiSurfaceReport, ApiSurfaceDeepError> {
let internal_crates: HashSet<&str> = internal_crates.iter().map(String::as_str).collect();
let ctx = DeepContext::load(&workspace.root).map_err(ApiSurfaceDeepError::Deep)?;
let db = ctx.raw_database();
let sema = Semantics::new(db);
ra_ap_hir::attach_db(db, || {
let mut report = DeepApiSurfaceReport::default();
for krate in &workspace.crates {
for file in &krate.source_files {
if !file.kind.is_locally_reportable() {
continue;
}
let Some(file_id) = ctx.file_id(&file.path).map_err(ApiSurfaceDeepError::Deep)?
else {
continue;
};
let Some(own_module) = sema.file_to_module_def(file_id) else {
continue;
};
let own_crate = own_module.krate(db);
let candidates = match api_surface::pub_fn_candidates(&file.path) {
Ok(candidates) => candidates,
Err(err) => {
report.errors.push(ApiSurfaceDeepError::ApiSurface(err));
continue;
}
};
for candidate in &candidates {
report.checked += 1;
report.findings.extend(leaked_types_for_candidate(
&sema, db, file_id, own_crate, candidate,
));
if !internal_crates.is_empty() {
let leaks =
resolve_leaks_for_candidate(&sema, db, file_id, own_crate, candidate);
report.findings.extend(
leaks
.iter()
.filter(|(_, leak)| {
internal_crates.contains(leak.defining_crate.as_str())
})
.map(|(site, leak)| internal_leak_finding(candidate, site, leak)),
);
}
}
}
}
report.findings.extend(
re_export_chain_findings(workspace, &ctx, &sema, db)
.map_err(ApiSurfaceDeepError::Deep)?,
);
Ok(report)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::TempDir;
fn write_crate(dir: &TempDir, name: &str, deps: &[(&str, &str)], lib_source: &str) {
std::fs::create_dir_all(dir.join(name).join("src")).unwrap();
let mut manifest =
format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n");
if !deps.is_empty() {
manifest.push_str("\n[dependencies]\n");
for (dep_name, rel_path) in deps {
manifest.push_str(&format!("{dep_name} = {{ path = \"{rel_path}\" }}\n"));
}
}
std::fs::write(dir.join(name).join("Cargo.toml"), manifest).unwrap();
std::fs::write(dir.join(name).join("src/lib.rs"), lib_source).unwrap();
}
fn write_workspace_manifest(dir: &TempDir, members: &[&str]) {
let members_toml = members
.iter()
.map(|m| format!("\"{m}\""))
.collect::<Vec<_>>()
.join(", ");
std::fs::write(
dir.join("Cargo.toml"),
format!("[workspace]\nmembers = [{members_toml}]\nresolver = \"2\"\n"),
)
.unwrap();
}
fn semver_hazard_findings(report: &DeepApiSurfaceReport) -> Vec<&Finding> {
report
.findings
.iter()
.filter(|f| f.rule == SEMVER_HAZARD_RULE)
.collect()
}
fn internal_leak_findings(report: &DeepApiSurfaceReport) -> Vec<&Finding> {
report
.findings
.iter()
.filter(|f| f.rule == INTERNAL_LEAK_RULE)
.collect()
}
#[test]
fn a_pub_fn_with_a_dependency_type_parameter_is_flagged() {
let dir = TempDir::new("api-surface-deep-leaked-param");
write_crate(
&dir,
"other",
&[],
"pub struct OtherCrateType {\n pub value: i32,\n}\n",
);
write_crate(
&dir,
"core_crate",
&[("other", "../other")],
"pub fn process(x: other::OtherCrateType) -> i32 {\n x.value\n}\n",
);
write_workspace_manifest(&dir, &["core_crate", "other"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, &[]).unwrap();
let hits = semver_hazard_findings(&report);
let hit = hits
.iter()
.find(|f| f.location.item_path == "process")
.expect("process(x: other::OtherCrateType) must be flagged");
assert_eq!(
hit.evidence.as_ref().unwrap()["kind"],
"leaked_dependency_type"
);
assert_eq!(
hit.evidence.as_ref().unwrap()["type_name"],
"OtherCrateType"
);
assert_eq!(hit.evidence.as_ref().unwrap()["defining_crate"], "other");
assert_eq!(hit.evidence.as_ref().unwrap()["site"], "parameter0");
assert_eq!(hit.severity, Severity::Info);
assert_eq!(hit.evidence_class, EvidenceClass::BoundedSemantic);
assert!(
internal_leak_findings(&report).is_empty(),
"empty internal_crates must produce zero internal-leak findings, even though process() leaks other::OtherCrateType: {:?}",
internal_leak_findings(&report)
);
}
#[test]
fn a_pub_fn_leaking_a_configured_internal_crate_type_is_flagged() {
let dir = TempDir::new("api-surface-deep-internal-leak-hit");
write_crate(
&dir,
"other",
&[],
"pub struct OtherCrateType {\n pub value: i32,\n}\n",
);
write_crate(
&dir,
"core_crate",
&[("other", "../other")],
"pub fn process(x: other::OtherCrateType) -> i32 {\n x.value\n}\n",
);
write_workspace_manifest(&dir, &["core_crate", "other"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, &["other".to_string()]).unwrap();
let hits = internal_leak_findings(&report);
let hit = hits
.iter()
.find(|f| f.location.item_path == "process")
.expect("process(x: other::OtherCrateType) must be flagged as internal-leak");
assert_eq!(hit.evidence.as_ref().unwrap()["kind"], "internal_leak");
assert_eq!(
hit.evidence.as_ref().unwrap()["type_name"],
"OtherCrateType"
);
assert_eq!(hit.evidence.as_ref().unwrap()["defining_crate"], "other");
assert_eq!(hit.severity, Severity::Warn);
assert_eq!(hit.evidence_class, EvidenceClass::BoundedSemantic);
assert!(
semver_hazard_findings(&report)
.iter()
.any(|f| f.location.item_path == "process"),
"internal-leak must not suppress the existing semver-hazard finding"
);
}
#[test]
fn a_pub_fn_leaking_a_non_configured_crate_type_is_not_flagged() {
let dir = TempDir::new("api-surface-deep-internal-leak-miss");
write_crate(&dir, "other", &[], "pub struct OtherCrateType;\n");
write_crate(
&dir,
"core_crate",
&[("other", "../other")],
"pub fn process(x: other::OtherCrateType) {\n let _ = x;\n}\n",
);
write_workspace_manifest(&dir, &["core_crate", "other"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, &["some-unrelated-crate".to_string()]).unwrap();
assert!(
internal_leak_findings(&report).is_empty(),
"internal_crates naming a different crate than the one that owns the leaked type must not fire: {:?}",
internal_leak_findings(&report)
);
}
#[test]
fn a_pub_fn_with_a_dependency_type_return_is_flagged() {
let dir = TempDir::new("api-surface-deep-leaked-return");
write_crate(&dir, "other", &[], "pub struct OtherCrateType;\n");
write_crate(
&dir,
"core_crate",
&[("other", "../other")],
"pub fn make() -> other::OtherCrateType {\n other::OtherCrateType\n}\n",
);
write_workspace_manifest(&dir, &["core_crate", "other"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, &[]).unwrap();
let hits = semver_hazard_findings(&report);
let hit = hits
.iter()
.find(|f| f.location.item_path == "make")
.expect("make() -> other::OtherCrateType must be flagged");
assert_eq!(hit.evidence.as_ref().unwrap()["site"], "return");
}
#[test]
fn a_pub_fn_with_only_own_and_std_types_is_not_flagged() {
let dir = TempDir::new("api-surface-deep-no-leak");
write_crate(
&dir,
"core_crate",
&[],
r#"pub struct Local {
pub value: i32,
}
pub fn process(x: Local, y: i32) -> String {
let _ = x.value;
let _ = y;
String::new()
}
"#,
);
write_workspace_manifest(&dir, &["core_crate"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, &[]).unwrap();
assert!(
semver_hazard_findings(&report)
.iter()
.all(|f| f.location.item_path != "process"),
"own/std types only must not be flagged: {:?}",
semver_hazard_findings(&report)
);
}
#[test]
fn a_pub_fn_with_a_dependency_type_one_level_inside_a_vec_is_flagged() {
let dir = TempDir::new("api-surface-deep-leaked-generic");
write_crate(&dir, "other", &[], "pub struct OtherCrateType;\n");
write_crate(
&dir,
"core_crate",
&[("other", "../other")],
"pub fn process(x: Vec<other::OtherCrateType>) -> usize {\n x.len()\n}\n",
);
write_workspace_manifest(&dir, &["core_crate", "other"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, &[]).unwrap();
let hits = semver_hazard_findings(&report);
let hit = hits
.iter()
.find(|f| f.location.item_path == "process")
.expect("process(x: Vec<other::OtherCrateType>) must be flagged one level deep");
assert_eq!(
hit.evidence.as_ref().unwrap()["type_name"],
"OtherCrateType"
);
}
#[test]
fn a_pub_fn_with_only_std_types_nested_in_a_generic_is_not_flagged() {
let dir = TempDir::new("api-surface-deep-no-leak-generic");
write_crate(
&dir,
"core_crate",
&[],
"pub fn process(x: Option<i32>) -> Option<i32> {\n x\n}\n",
);
write_workspace_manifest(&dir, &["core_crate"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, &[]).unwrap();
assert!(
semver_hazard_findings(&report)
.iter()
.all(|f| f.location.item_path != "process"),
"Option<i32> is std-only, even nested: {:?}",
semver_hazard_findings(&report)
);
}
#[test]
fn a_pub_method_in_an_inherent_impl_with_a_dependency_type_parameter_is_flagged() {
let dir = TempDir::new("api-surface-deep-inherent-impl");
write_crate(&dir, "other", &[], "pub struct OtherCrateType;\n");
write_crate(
&dir,
"core_crate",
&[("other", "../other")],
r#"pub struct Local;
impl Local {
pub fn process(&self, x: other::OtherCrateType) {
let _ = x;
}
}
"#,
);
write_workspace_manifest(&dir, &["core_crate", "other"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, &[]).unwrap();
assert!(
semver_hazard_findings(&report)
.iter()
.any(|f| f.location.item_path == "Local::process"),
"inherent-impl method with a leaked type parameter must be flagged: {:?}",
semver_hazard_findings(&report)
);
}
#[test]
fn a_pub_fn_with_a_non_member_dependency_type_is_flagged() {
let dir = TempDir::new("api-surface-deep-external-dep");
write_crate(&dir, "external", &[], "pub struct ExternalType;\n");
write_crate(
&dir,
"core_crate",
&[("external", "../external")],
"pub fn accept(x: external::ExternalType) {\n let _ = x;\n}\n",
);
write_workspace_manifest(&dir, &["core_crate"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, &[]).unwrap();
assert!(
semver_hazard_findings(&report)
.iter()
.any(|f| f.location.item_path == "accept"),
"a type from a dependency crate outside the workspace member list must be flagged: {:?}",
semver_hazard_findings(&report)
);
}
fn re_export_chain_findings_in(report: &DeepApiSurfaceReport) -> Vec<&Finding> {
report
.findings
.iter()
.filter(|f| f.rule == RE_EXPORT_CHAIN_RULE)
.collect()
}
#[test]
fn a_direct_single_reexport_is_not_flagged() {
let dir = TempDir::new("api-surface-deep-reexport-single-hop");
write_crate(&dir, "other", &[], "pub struct Foo;\n");
write_crate(
&dir,
"core_crate",
&[("other", "../other")],
"pub use other::Foo;\n",
);
write_workspace_manifest(&dir, &["core_crate", "other"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, &[]).unwrap();
assert!(
re_export_chain_findings_in(&report).is_empty(),
"a single-hop pub use re-export must not be flagged: {:?}",
re_export_chain_findings_in(&report)
);
}
#[test]
fn a_two_hop_reexport_chain_is_flagged() {
let dir = TempDir::new("api-surface-deep-reexport-two-hops");
write_crate(&dir, "crate_c", &[], "pub struct Foo;\n");
write_crate(
&dir,
"crate_b",
&[("crate_c", "../crate_c")],
"pub use crate_c::Foo;\n",
);
write_crate(
&dir,
"crate_a",
&[("crate_b", "../crate_b")],
"pub use crate_b::Foo;\n",
);
write_workspace_manifest(&dir, &["crate_a", "crate_b", "crate_c"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, &[]).unwrap();
let hits = re_export_chain_findings_in(&report);
let hit = hits
.iter()
.find(|f| f.location.item_path == "crate_a::Foo")
.unwrap_or_else(|| panic!("crate_a::Foo's two-hop chain must be flagged: {hits:?}"));
assert_eq!(hit.evidence.as_ref().unwrap()["hop_count"], 2);
assert_eq!(
hit.evidence.as_ref().unwrap()["defining_path"],
"crate_c::Foo"
);
assert_eq!(
hit.evidence.as_ref().unwrap()["exported_path"],
"crate_a::Foo"
);
assert_eq!(hit.evidence.as_ref().unwrap()["capped"], false);
assert_eq!(hit.severity, Severity::Info);
assert_eq!(hit.evidence_class, EvidenceClass::Heuristic);
}
#[test]
fn a_chain_deeper_than_the_cap_is_reported_as_capped() {
let dir = TempDir::new("api-surface-deep-reexport-capped");
write_crate(&dir, "crate_g", &[], "pub struct Foo;\n");
write_crate(
&dir,
"crate_f",
&[("crate_g", "../crate_g")],
"pub use crate_g::Foo;\n",
);
write_crate(
&dir,
"crate_e",
&[("crate_f", "../crate_f")],
"pub use crate_f::Foo;\n",
);
write_crate(
&dir,
"crate_d",
&[("crate_e", "../crate_e")],
"pub use crate_e::Foo;\n",
);
write_crate(
&dir,
"crate_c",
&[("crate_d", "../crate_d")],
"pub use crate_d::Foo;\n",
);
write_crate(
&dir,
"crate_b",
&[("crate_c", "../crate_c")],
"pub use crate_c::Foo;\n",
);
write_crate(
&dir,
"crate_a",
&[("crate_b", "../crate_b")],
"pub use crate_b::Foo;\n",
);
write_workspace_manifest(
&dir,
&[
"crate_a", "crate_b", "crate_c", "crate_d", "crate_e", "crate_f", "crate_g",
],
);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, &[]).unwrap();
let hits = re_export_chain_findings_in(&report);
let hit = hits
.iter()
.find(|f| f.location.item_path == "crate_a::Foo")
.unwrap_or_else(|| panic!("crate_a::Foo's deep chain must be flagged: {hits:?}"));
assert_eq!(hit.evidence.as_ref().unwrap()["hop_count"], 5);
assert_eq!(hit.evidence.as_ref().unwrap()["capped"], true);
}
#[test]
fn a_pub_use_cycle_does_not_panic_or_hang() {
let dir = TempDir::new("api-surface-deep-reexport-cycle");
write_crate(
&dir,
"crate_x",
&[("crate_y", "../crate_y")],
"pub use crate_y::Foo;\n",
);
write_crate(
&dir,
"crate_y",
&[("crate_x", "../crate_x")],
"pub use crate_x::Foo;\n",
);
write_workspace_manifest(&dir, &["crate_x", "crate_y"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, &[]).unwrap();
assert!(
re_export_chain_findings_in(&report)
.iter()
.all(|f| f.location.item_path != "crate_x::Foo"
&& f.location.item_path != "crate_y::Foo"),
"an unresolvable pub use cycle must not be reported as a resolved chain: {:?}",
re_export_chain_findings_in(&report)
);
}
#[test]
fn an_item_with_no_reexport_is_not_flagged() {
let dir = TempDir::new("api-surface-deep-reexport-none");
write_crate(
&dir,
"core_crate",
&[],
"pub struct Foo;\n\npub fn make() -> Foo {\n Foo\n}\n",
);
write_workspace_manifest(&dir, &["core_crate"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, &[]).unwrap();
assert!(
re_export_chain_findings_in(&report).is_empty(),
"an item with no pub use anywhere must not be flagged: {:?}",
re_export_chain_findings_in(&report)
);
}
fn split_marked_crates(source: &str) -> Vec<(&str, String)> {
let mut result: Vec<(&str, String)> = Vec::new();
for line in source.lines() {
if let Some(name) = line.strip_prefix("// crate: ") {
result.push((name, String::new()));
} else if let Some(entry) = result.last_mut() {
entry.1.push_str(line);
entry.1.push('\n');
}
}
result
}
#[cfg(feature = "deep")]
#[test]
fn re_export_chain_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(RE_EXPORT_CHAIN_RULE)
.expect("re-export-chain has a registry entry")
.example
.expect("re-export-chain has a curated example")
.before;
let crates = split_marked_crates(example);
assert_eq!(crates.len(), 3, "expected 3 marked crates: {crates:?}");
let crate0 = crates[0].0;
let crate1 = crates[1].0;
let crate2 = crates[2].0;
let dep1 = format!("../{crate0}");
let dep2 = format!("../{crate1}");
let dir = TempDir::new("api-surface-deep-reexport-registry-example");
write_crate(&dir, crate0, &[], &crates[0].1);
write_crate(&dir, crate1, &[(crate0, &dep1)], &crates[1].1);
write_crate(&dir, crate2, &[(crate1, &dep2)], &crates[2].1);
write_workspace_manifest(&dir, &[crate0, crate1, crate2]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, &[]).unwrap();
assert_eq!(
re_export_chain_findings_in(&report).len(),
1,
"{:?}",
report.findings
);
}
}