#![deny(missing_docs)]
use std::collections::{HashMap, HashSet};
use std::path::Path;
use serde_json::Value;
mod resolve;
use resolve::{
BareFallback, apply_bare_alias_rename, apply_crate_root_rename, bare_local_alias,
canonical_path_str, canonical_self_owner, canonicalize_through_aliases,
canonicalize_through_reexports, collect_uses, extern_verbatim_renamed, renames_shadowed,
resolve_path,
};
pub use xuanji::{
Baseline, BoundaryKind, Outcome, Polarity, Report, Severity, Violation, ViolationId,
apply_baseline,
};
pub const SIGNATURE_RULE: &str = "must not expose";
pub const DYN_TRAIT_RULE: &str = "must not expose dyn";
pub const IMPL_TRAIT_RULE: &str = "must not expose impl trait";
pub const ASYNC_EXPOSURE_RULE: &str = "must not expose async fn";
pub const TRAIT_IMPL_RULE: &str = "must only be implemented in the declared location(s)";
pub const VISIBILITY_RULE: &str = "must not declare pub items";
pub const FORBIDDEN_MARKER_RULE: &str = "must not acquire trait";
mod dsl;
pub use dsl::*;
mod metadata;
use metadata::cargo_metadata;
mod collect;
mod finding;
mod module_resolve;
mod scan;
use collect::{
collect_item_async_exposures, collect_item_dyn_exposures, collect_item_exposures,
collect_item_return_impl_traits, collect_trait_impl_exposures,
};
use finding::{SemanticFinding, shape_finding};
use module_resolve::resolve_module_items;
use scan::scan_crate;
mod containment;
mod crate_scope;
mod emit;
mod errors;
mod file_scope;
mod syn_util;
use containment::{
leaf_of, matches_allowed, matches_forbidden, path_leaf, resolve_self_type, under_subtree,
};
use crate_scope::{
child_module_names, dependency_names, extern_resolution, external_crate_set,
local_type_namespace_names, resolve_principal,
};
use emit::{SingleModuleViolationContext, push_single_module_violations};
use errors::{unknown_trait_error, unreadable_workspace_error};
use file_scope::{per_finding_file, resolve_crate};
use syn_util::pub_item_description;
#[derive(Debug, Clone, Default)]
pub struct SemanticBoundaries {
pub signature: Vec<SemanticBoundary>,
pub trait_impl: Vec<TraitImplBoundary>,
pub visibility: Vec<VisibilityBoundary>,
pub forbidden_marker: Vec<ForbiddenMarkerBoundary>,
pub dyn_trait: Vec<DynTraitBoundary>,
pub impl_trait: Vec<ImplTraitBoundary>,
pub async_exposure: Vec<AsyncExposureBoundary>,
}
impl SemanticBoundaries {
pub fn is_empty(&self) -> bool {
self.signature.is_empty()
&& self.trait_impl.is_empty()
&& self.visibility.is_empty()
&& self.forbidden_marker.is_empty()
&& self.dyn_trait.is_empty()
&& self.impl_trait.is_empty()
&& self.async_exposure.is_empty()
}
}
fn outcome_from(violations: Vec<Violation>) -> Outcome {
let mut deduped: Vec<Violation> = Vec::new();
for violation in violations {
match deduped.iter_mut().find(|kept| kept.id() == violation.id()) {
Some(kept) => {
if kept.severity == Severity::Warn && violation.severity == Severity::Enforce {
*kept = violation;
}
}
None => deduped.push(violation),
}
}
if deduped.is_empty() {
Outcome::Clean
} else {
Outcome::Violations(Report::new(deduped))
}
}
fn read_metadata(manifest_path: &Path) -> Result<Value, Outcome> {
cargo_metadata(manifest_path)
.map_err(|err| Outcome::ConstitutionError(unreadable_workspace_error(manifest_path, &err)))
}
fn eval_into<B>(
metadata: &Value,
boundaries: &[B],
per_boundary: impl Fn(&Value, &B, &mut Vec<Violation>) -> Result<(), String>,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
for boundary in boundaries {
per_boundary(metadata, boundary, violations)?;
}
Ok(())
}
fn run_boundaries<B>(
boundaries: &[B],
manifest_path: &Path,
per_boundary: impl Fn(&Value, &B, &mut Vec<Violation>) -> Result<(), String>,
) -> Outcome {
let metadata = match read_metadata(manifest_path) {
Ok(metadata) => metadata,
Err(outcome) => return outcome,
};
let mut violations = Vec::new();
match eval_into(&metadata, boundaries, per_boundary, &mut violations) {
Ok(()) => outcome_from(violations),
Err(error) => Outcome::ConstitutionError(error),
}
}
fn eval_all(
metadata: &Value,
boundaries: &SemanticBoundaries,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
eval_into(metadata, &boundaries.signature, check_boundary, violations)?;
eval_into(
metadata,
&boundaries.trait_impl,
check_trait_impl_boundary,
violations,
)?;
eval_into(
metadata,
&boundaries.visibility,
check_visibility_boundary,
violations,
)?;
eval_into(
metadata,
&boundaries.forbidden_marker,
check_forbidden_marker_boundary,
violations,
)?;
eval_into(
metadata,
&boundaries.dyn_trait,
check_dyn_trait_boundary,
violations,
)?;
eval_into(
metadata,
&boundaries.impl_trait,
check_impl_trait_boundary,
violations,
)?;
eval_into(
metadata,
&boundaries.async_exposure,
check_async_exposure_boundary,
violations,
)?;
Ok(())
}
pub fn check_all(boundaries: &SemanticBoundaries, manifest_path: &Path) -> Outcome {
let metadata = match read_metadata(manifest_path) {
Ok(metadata) => metadata,
Err(outcome) => return outcome,
};
let mut violations = Vec::new();
match eval_all(&metadata, boundaries, &mut violations) {
Ok(()) => outcome_from(violations),
Err(error) => Outcome::ConstitutionError(error),
}
}
pub fn check(boundaries: &[SemanticBoundary], manifest_path: &Path) -> Outcome {
run_boundaries(boundaries, manifest_path, check_boundary)
}
fn check_boundary(
metadata: &Value,
boundary: &SemanticBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let (package, root_file, src_dir) = resolve_crate(metadata, &boundary.crate_package)?;
let src_dir = src_dir.as_path();
let findings = module_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.forbidden,
&boundary.crate_package,
boundary.including_trait_impls,
&dependency_names(package),
)?;
push_single_module_violations(
violations,
SingleModuleViolationContext {
src_dir,
root_file: &root_file,
module: &boundary.module,
crate_package: &boundary.crate_package,
rule: SIGNATURE_RULE,
reason: &boundary.reason,
severity: boundary.severity,
anchor: boundary.anchor(),
},
findings,
)
}
pub(crate) fn module_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
forbidden: &[String],
crate_package: &str,
include_trait_impls: bool,
dep_names: &[String],
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let uses = collect_uses(&items);
let externs = external_crate_set(dep_names);
let externs_type: HashSet<String> = externs
.difference(&local_type_namespace_names(&items))
.cloned()
.collect();
let child_mods = child_module_names(&items);
let externs_reexport: HashSet<String> = externs.difference(&child_mods).cloned().collect();
let scan = scan_crate(src_dir, root_file, crate_package, &externs)?;
let reexports = scan.reexports;
let aliases = scan.aliases;
let extern_renames = scan.extern_renames;
let renames_bare = renames_shadowed(&extern_renames, &child_mods);
let forbidden: Vec<String> = forbidden.iter().map(|f| canonical_path_str(f)).collect();
let mut exposed = Vec::new();
for (ordinal, item) in items.iter().enumerate() {
collect_item_exposures(item, module, &uses, ordinal, &mut exposed);
if include_trait_impls {
collect_trait_impl_exposures(item, module, &uses, ordinal, &mut exposed);
}
}
let mut findings: Vec<String> = exposed
.iter()
.filter_map(|exposure| {
let type_externs = if exposure.is_reexport {
&externs_reexport
} else {
&externs_type
};
let resolved = if exposure.path.leading_colon.is_some() {
extern_verbatim_renamed(&exposure.path, &externs, &extern_renames)
} else {
resolve_path(&exposure.path, &uses, module, BareFallback::Ignore)
.or_else(|| bare_local_alias(&exposure.path, module, &aliases))
.or_else(|| {
extern_verbatim_renamed(&exposure.path, type_externs, &renames_bare)
})
};
resolved
.map(|canonical| canonicalize_through_aliases(&canonical, &aliases, &reexports))
.map(|canonical| apply_crate_root_rename(canonical, &extern_renames))
.map(|canonical| apply_bare_alias_rename(canonical, &renames_bare))
.filter(|canonical| matches_forbidden(canonical, &forbidden))
.map(|canonical| {
SemanticFinding::Exposed {
subject: canonical,
seam: exposure.seam.clone(),
}
.to_string()
})
})
.collect();
findings.sort();
findings.dedup();
Ok(findings)
}
pub fn check_dyn_trait(boundaries: &[DynTraitBoundary], manifest_path: &Path) -> Outcome {
run_boundaries(boundaries, manifest_path, check_dyn_trait_boundary)
}
fn check_dyn_trait_boundary(
metadata: &Value,
boundary: &DynTraitBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let (package, root_file, src_dir) = resolve_crate(metadata, &boundary.crate_package)?;
let src_dir = src_dir.as_path();
let findings = if boundary.forbidden_operands.is_empty() {
dyn_module_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.crate_package,
)?
} else {
dyn_operand_module_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.forbidden_operands,
&boundary.crate_package,
&dependency_names(package),
)?
};
push_single_module_violations(
violations,
SingleModuleViolationContext {
src_dir,
root_file: &root_file,
module: &boundary.module,
crate_package: &boundary.crate_package,
rule: DYN_TRAIT_RULE,
reason: &boundary.reason,
severity: boundary.severity,
anchor: boundary.anchor(),
},
findings,
)
}
pub(crate) fn dyn_module_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
crate_package: &str,
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let uses = collect_uses(&items);
let mut exposures = Vec::new();
for (ordinal, item) in items.iter().enumerate() {
collect_item_dyn_exposures(item, module, &uses, ordinal, &mut exposures);
}
let mut findings: Vec<String> = exposures.into_iter().map(shape_finding).collect();
findings.sort();
findings.dedup();
Ok(findings)
}
pub(crate) fn dyn_operand_module_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
forbidden: &[String],
crate_package: &str,
dep_names: &[String],
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let uses = collect_uses(&items);
let resolution = extern_resolution(src_dir, root_file, crate_package, dep_names, &items)?;
let forbidden: Vec<String> = forbidden.iter().map(|f| canonical_path_str(f)).collect();
let mut exposures = Vec::new();
for (ordinal, item) in items.iter().enumerate() {
collect_item_dyn_exposures(item, module, &uses, ordinal, &mut exposures);
}
let mut findings: Vec<String> = exposures
.into_iter()
.filter(|exposure| {
forbidden.is_empty()
|| exposure
.principal
.as_ref()
.and_then(|path| resolve_principal(path, &uses, module, &resolution))
.is_some_and(|canonical| matches_forbidden(&canonical, &forbidden))
})
.map(shape_finding)
.collect();
findings.sort();
findings.dedup();
Ok(findings)
}
pub fn check_impl_trait(boundaries: &[ImplTraitBoundary], manifest_path: &Path) -> Outcome {
run_boundaries(boundaries, manifest_path, check_impl_trait_boundary)
}
fn check_impl_trait_boundary(
metadata: &Value,
boundary: &ImplTraitBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let (package, root_file, src_dir) = resolve_crate(metadata, &boundary.crate_package)?;
let src_dir = src_dir.as_path();
let findings = if boundary.forbidden_operands.is_empty() {
impl_trait_module_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.crate_package,
)?
} else {
impl_trait_operand_module_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.forbidden_operands,
&boundary.crate_package,
&dependency_names(package),
)?
};
push_single_module_violations(
violations,
SingleModuleViolationContext {
src_dir,
root_file: &root_file,
module: &boundary.module,
crate_package: &boundary.crate_package,
rule: IMPL_TRAIT_RULE,
reason: &boundary.reason,
severity: boundary.severity,
anchor: boundary.anchor(),
},
findings,
)
}
pub(crate) fn impl_trait_module_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
crate_package: &str,
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let uses = collect_uses(&items);
let mut exposures = Vec::new();
for (ordinal, item) in items.iter().enumerate() {
collect_item_return_impl_traits(item, module, &uses, ordinal, &mut exposures);
}
let mut findings: Vec<String> = exposures.into_iter().map(shape_finding).collect();
findings.sort();
findings.dedup();
Ok(findings)
}
pub(crate) fn impl_trait_operand_module_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
forbidden: &[String],
crate_package: &str,
dep_names: &[String],
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let uses = collect_uses(&items);
let resolution = extern_resolution(src_dir, root_file, crate_package, dep_names, &items)?;
let forbidden: Vec<String> = forbidden.iter().map(|f| canonical_path_str(f)).collect();
let mut exposures = Vec::new();
for (ordinal, item) in items.iter().enumerate() {
collect_item_return_impl_traits(item, module, &uses, ordinal, &mut exposures);
}
let mut findings: Vec<String> = exposures
.into_iter()
.filter(|exposure| {
forbidden.is_empty()
|| exposure
.principal
.as_ref()
.and_then(|path| resolve_principal(path, &uses, module, &resolution))
.is_some_and(|canonical| matches_forbidden(&canonical, &forbidden))
})
.map(shape_finding)
.collect();
findings.sort();
findings.dedup();
Ok(findings)
}
pub fn check_async_exposure(boundaries: &[AsyncExposureBoundary], manifest_path: &Path) -> Outcome {
run_boundaries(boundaries, manifest_path, check_async_exposure_boundary)
}
fn check_async_exposure_boundary(
metadata: &Value,
boundary: &AsyncExposureBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let (_package, root_file, src_dir) = resolve_crate(metadata, &boundary.crate_package)?;
let src_dir = src_dir.as_path();
let findings = async_exposure_module_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.crate_package,
)?;
push_single_module_violations(
violations,
SingleModuleViolationContext {
src_dir,
root_file: &root_file,
module: &boundary.module,
crate_package: &boundary.crate_package,
rule: ASYNC_EXPOSURE_RULE,
reason: &boundary.reason,
severity: boundary.severity,
anchor: boundary.anchor(),
},
findings,
)
}
pub(crate) fn async_exposure_module_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
crate_package: &str,
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let uses = collect_uses(&items);
let mut found = Vec::new();
for (ordinal, item) in items.iter().enumerate() {
collect_item_async_exposures(item, module, &uses, ordinal, &mut found);
}
found.sort();
found.dedup();
Ok(found)
}
pub fn check_trait_impl_locality(
boundaries: &[TraitImplBoundary],
manifest_path: &Path,
) -> Outcome {
run_boundaries(boundaries, manifest_path, check_trait_impl_boundary)
}
fn check_trait_impl_boundary(
metadata: &Value,
boundary: &TraitImplBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let (_package, root_file, src_dir) = resolve_crate(metadata, &boundary.crate_package)?;
let src_dir = src_dir.as_path();
let findings = trait_impl_findings(
src_dir,
&root_file,
&boundary.trait_path,
&boundary.allowed_locations,
&boundary.crate_package,
)?;
let rule = TRAIT_IMPL_RULE.to_string();
let mut file_cache: HashMap<String, Option<String>> = HashMap::new();
for (finding, module) in findings {
let file = per_finding_file(
&module,
src_dir,
&root_file,
&boundary.crate_package,
&mut file_cache,
);
violations.push(
Violation::new(
BoundaryKind::Semantic,
canonical_path_str(&boundary.trait_path),
rule.clone(),
finding,
boundary.reason.clone(),
boundary.severity,
)
.with_file(file)
.with_anchor(boundary.anchor.clone())
.with_polarity(Polarity::AllowlistGap),
);
}
Ok(())
}
pub(crate) fn trait_impl_findings(
src_dir: &Path,
root_file: &Path,
trait_path: &str,
allowed: &[String],
crate_package: &str,
) -> Result<Vec<(String, String)>, String> {
let scan = scan_crate(src_dir, root_file, crate_package, &HashSet::new())?;
let given = canonical_path_str(trait_path);
let true_anchor = canonicalize_through_reexports(&given, &scan.reexports);
if !scan.trait_defs.contains(&true_anchor) {
return Err(unknown_trait_error(trait_path, crate_package));
}
let allowed: Vec<String> = allowed.iter().map(|a| canonical_path_str(a)).collect();
let mut findings = Vec::new();
for (ordinal, site) in scan.impls.iter().enumerate() {
let Some(resolved) = resolve_path(
&site.trait_path,
&site.uses,
&site.module,
BareFallback::CurrentModule,
) else {
continue;
};
let canonical = canonicalize_through_reexports(&resolved, &scan.reexports);
if canonical != true_anchor {
continue;
}
if matches_allowed(&site.module, &allowed) {
continue;
}
let owner = canonical_self_owner(&site.self_ty, &site.uses, &site.module, ordinal);
findings.push((
SemanticFinding::MisplacedImpl {
module: site.module.clone(),
owner,
}
.to_string(),
site.module.clone(),
));
}
findings.sort();
findings.dedup_by(|a, b| a.0 == b.0);
Ok(findings)
}
pub fn check_visibility(boundaries: &[VisibilityBoundary], manifest_path: &Path) -> Outcome {
run_boundaries(boundaries, manifest_path, check_visibility_boundary)
}
fn check_visibility_boundary(
metadata: &Value,
boundary: &VisibilityBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let (_package, root_file, src_dir) = resolve_crate(metadata, &boundary.crate_package)?;
let src_dir = src_dir.as_path();
let findings = visibility_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.crate_package,
)?;
push_single_module_violations(
violations,
SingleModuleViolationContext {
src_dir,
root_file: &root_file,
module: &boundary.module,
crate_package: &boundary.crate_package,
rule: VISIBILITY_RULE,
reason: &boundary.reason,
severity: boundary.severity,
anchor: boundary.anchor(),
},
findings,
)
}
pub(crate) fn visibility_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
crate_package: &str,
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let mut findings: Vec<String> = items.iter().filter_map(pub_item_description).collect();
findings.sort();
findings.dedup();
Ok(findings)
}
pub fn check_forbidden_marker(
boundaries: &[ForbiddenMarkerBoundary],
manifest_path: &Path,
) -> Outcome {
run_boundaries(boundaries, manifest_path, check_forbidden_marker_boundary)
}
fn check_forbidden_marker_boundary(
metadata: &Value,
boundary: &ForbiddenMarkerBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let (_package, root_file, src_dir) = resolve_crate(metadata, &boundary.crate_package)?;
let src_dir = src_dir.as_path();
let findings = forbidden_marker_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.forbidden,
&boundary.crate_package,
)?;
let mut file_cache: HashMap<String, Option<String>> = HashMap::new();
for (finding, module) in findings {
let file = per_finding_file(
&module,
src_dir,
&root_file,
&boundary.crate_package,
&mut file_cache,
);
violations.push(
Violation::new(
BoundaryKind::Semantic,
boundary.module.clone(),
FORBIDDEN_MARKER_RULE.to_string(),
finding,
boundary.reason.clone(),
boundary.severity,
)
.with_file(file)
.with_anchor(boundary.anchor.clone())
.with_polarity(Polarity::DenyBreach),
);
}
Ok(())
}
pub(crate) fn forbidden_marker_findings(
src_dir: &Path,
root_file: &Path,
subtree: &str,
forbidden: &[String],
crate_package: &str,
) -> Result<Vec<(String, String)>, String> {
let scan = scan_crate(src_dir, root_file, crate_package, &HashSet::new())?;
let subtree = canonical_path_str(subtree);
let mut findings = Vec::new();
for entry in forbidden {
let entry_leaf = leaf_of(entry);
for td in &scan.type_defs {
if !under_subtree(&td.canonical, &subtree) {
continue;
}
for derived in &td.derives {
if path_leaf(derived) == entry_leaf {
findings.push((
SemanticFinding::ForbiddenDerive {
marker: entry.clone(),
canonical: td.canonical.clone(),
}
.to_string(),
td.module.clone(),
));
}
}
}
for site in &scan.impls {
if path_leaf(&site.trait_path) != entry_leaf {
continue;
}
let Some(self_canonical) = resolve_self_type(&site.self_ty, &site.uses, &site.module)
else {
continue; };
if under_subtree(&self_canonical, &subtree) {
findings.push((
format!("impl {entry} for {self_canonical}"),
site.module.clone(),
));
}
}
}
findings.sort();
findings.dedup_by(|a, b| a.0 == b.0);
Ok(findings)
}
#[cfg(test)]
mod tests;