//! Shared managed assembly metadata helpers. use std::collections::{BTreeMap, HashMap}; use std::fmt::Write as _; use std::fs; use std::path::{Path, PathBuf}; use clrmeta::reader::Reader; use clrmeta::{ AssemblyInfo, AssemblyRefInfo, CodedIndex, CodedIndexKind, FieldSig, Metadata, MethodSig, PropertySig, ResolvedType, TableId, TypeSig, }; use goblin::pe::PE; use goblin::pe::section_table::SectionTable; use regex_lite::Regex; use serde::Serialize; use thiserror::Error; mod flow; mod reference_diagnose; pub use flow::*; pub use reference_diagnose::*; /// Errors produced while inspecting managed assemblies. #[derive(Debug, Error)] pub enum ManagedError { /// The assembly path could not be read. #[error("failed to read {path}: {message}")] Read { /// Path that failed. path: PathBuf, /// Error detail. message: String, }, /// The file is not a PE image. #[error("{path} is not a PE image")] NotPe { /// Path that failed. path: PathBuf, }, /// The PE image does not contain CLR metadata. #[error("{path} is not a managed .NET assembly")] NotManaged { /// Path that failed. path: PathBuf, }, /// CLR metadata could not be parsed. #[error("failed to parse CLR metadata from {path}: {message}")] Metadata { /// Path that failed. path: PathBuf, /// Error detail. message: String, }, /// The requested managed query could not be resolved uniquely. #[error("{message}")] Query { /// Human-readable query failure detail. message: String, }, } /// Filters for type queries. #[derive(Debug, Clone, Default)] pub struct TypeQuery { /// Optional regex applied to the full type name. pub match_pattern: Option, /// Optional regex applied to the namespace. pub namespace_pattern: Option, /// Optional type-kind filter. pub kind: Option, /// Restrict results to public or nested-public types. pub public_only: bool, /// Optional regex applied to the resolved base type full name. pub base_pattern: Option, /// Optional regex applied to any resolved interface full name. pub interface_pattern: Option, /// Maximum number of rows to return. pub limit: Option, } /// Filters for member queries. #[derive(Debug, Clone, Default)] pub struct MemberQuery { /// Type full names to inspect. pub type_names: Vec, /// Optional member-kind filter. pub kind: Option, /// Optional regex applied to member names. pub match_pattern: Option, /// Binding-style visibility filter. pub binding: BindingFilter, /// Whether to include compiler special-name methods in method output. pub include_special: bool, /// Whether to hide compiler-generated backing fields and closure artifacts. pub user_code_only: bool, /// Maximum number of rows to return. pub limit: Option, } /// Binding-style visibility and scope filters. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[allow(clippy::struct_excessive_bools)] pub struct BindingFilter { /// Include public members. pub include_public: bool, /// Include non-public members. pub include_non_public: bool, /// Include instance members. pub include_instance: bool, /// Include static members. pub include_static: bool, } impl Default for BindingFilter { fn default() -> Self { Self { include_public: true, include_non_public: false, include_instance: true, include_static: true, } } } /// Query settings for reference inspection. #[derive(Debug, Clone, Default)] pub struct ReferenceQuery { /// Additional directories to search while resolving references. pub resolve_dirs: Vec, } /// Summary of one managed assembly. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct AssemblyDescriptor { /// Assembly file path. pub path: PathBuf, /// Simple assembly name. pub assembly_name: String, /// Assembly version in dotted form. pub assembly_version: Option, /// CLR metadata runtime version string. pub runtime_version: String, /// Whether the assembly contains IL-only code. pub is_il_only: bool, /// Whether the assembly is marked as a library. pub is_library: bool, /// Whether the assembly is strong-name signed. pub is_strong_name_signed: bool, /// Assembly public key token, when available. pub public_key_token: Option, } /// Type inspection output. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct TypeDescriptor { /// Assembly file path. pub assembly_path: PathBuf, /// Simple assembly name. pub assembly_name: String, /// Full type name. pub full_name: String, /// Namespace, if any. pub namespace: Option, /// Simple type name. pub name: String, /// Type kind label. pub kind: String, /// Visibility label. pub visibility: String, /// Whether the type is public or nested public. pub is_public: bool, /// Whether the type is abstract. pub is_abstract: bool, /// Whether the type is sealed. pub is_sealed: bool, /// Resolved base type full name, if any. pub base_type: Option, /// Resolved interface full names. pub interfaces: Vec, } /// Parameter metadata for method or property signatures. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ParameterDescriptor { /// Parameter name, if present in metadata. pub name: Option, /// Rendered parameter type. pub parameter_type: String, } /// Member inspection output. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum MemberDescriptor { /// Method output. Method { /// Assembly file path. assembly_path: PathBuf, /// Simple assembly name. assembly_name: String, /// Declaring type full name. type_name: String, /// Method name. name: String, /// Visibility label. visibility: String, /// Whether the method is static. is_static: bool, /// Whether the method is virtual. is_virtual: bool, /// Whether the method is abstract. is_abstract: bool, /// Return type. return_type: String, /// Parameter list. parameters: Vec, /// Human-readable signature. signature: String, }, /// Field output. Field { /// Assembly file path. assembly_path: PathBuf, /// Simple assembly name. assembly_name: String, /// Declaring type full name. type_name: String, /// Field name. name: String, /// Visibility label. visibility: String, /// Whether the field is static. is_static: bool, /// Whether the field is a literal constant. is_literal: bool, /// Whether the field is init-only. is_init_only: bool, /// Field type. field_type: String, /// Human-readable signature. signature: String, }, /// Property output. Property { /// Assembly file path. assembly_path: PathBuf, /// Simple assembly name. assembly_name: String, /// Declaring type full name. type_name: String, /// Property name. name: String, /// Effective visibility label. visibility: String, /// Whether the property is static. is_static: bool, /// Property type. property_type: String, /// Indexer parameters, if any. parameters: Vec, /// Getter visibility, if present. getter_visibility: Option, /// Setter visibility, if present. setter_visibility: Option, /// Human-readable signature. signature: String, }, } /// Visibility scope used by managed API diffing. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum ApiVisibilityScope { /// Public and nested-public types plus public methods. #[default] Public, /// Public and assembly/protected API, excluding private members. Internal, /// Every metadata-visible type and method. All, } impl ApiVisibilityScope { /// Returns true when the type visibility is included in this scope. #[must_use] pub fn includes_type(self, visibility: &str) -> bool { match self { Self::Public => matches!(visibility, "public" | "nested_public"), Self::Internal => !matches!(visibility, "nested_private"), Self::All => true, } } /// Returns true when the member visibility is included in this scope. #[must_use] pub fn includes_member(self, visibility: &str) -> bool { match self { Self::Public => visibility == "public", Self::Internal => visibility != "private", Self::All => true, } } } /// Query settings for managed API diffing. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ApiDiffQuery { /// Visibility scope to compare. pub visibility: ApiVisibilityScope, /// Include special-name methods such as property accessors. pub include_special: bool, /// Include `MissingMethodException` risk rows in the report. pub include_missing_method_risks: bool, } impl Default for ApiDiffQuery { fn default() -> Self { Self { visibility: ApiVisibilityScope::Public, include_special: false, include_missing_method_risks: true, } } } /// Normalized managed type row used by API diffing. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ApiTypeRow { /// Full type name. pub full_name: String, /// Namespace, if any. pub namespace: Option, /// Simple type name. pub name: String, /// Type kind label. pub kind: String, /// Visibility label. pub visibility: String, /// Whether the type is public or nested public. pub is_public: bool, } /// Normalized managed method row used by API diffing. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ApiMethodRow { /// Declaring type full name. pub type_name: String, /// Method name. pub name: String, /// Visibility label. pub visibility: String, /// Whether the method is static. pub is_static: bool, /// Whether the method is virtual. pub is_virtual: bool, /// Whether the method is abstract. pub is_abstract: bool, /// Return type. pub return_type: String, /// Parameter list. pub parameters: Vec, /// Human-readable method signature. pub signature: String, } /// Normalized API surface for one managed assembly. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct AssemblyApiSnapshot { /// Assembly summary. pub assembly: AssemblyDescriptor, /// Normalized type rows. pub types: Vec, /// Normalized method rows. pub methods: Vec, } /// Summary counts for a managed API diff report. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ApiDiffSummary { /// Removed type count. pub removed_types: usize, /// Added type count. pub added_types: usize, /// Removed method count. pub removed_methods: usize, /// Added method count. pub added_methods: usize, /// Changed method-name group count. pub signature_changed_methods: usize, /// `MissingMethodException` risk row count. pub missing_method_risks: usize, } /// Added or removed type. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ApiTypeChange { /// Full type name. pub type_name: String, /// Type kind label. pub kind: String, /// Visibility label. pub visibility: String, } /// Added or removed method, or a changed method-name group. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ApiMethodChange { /// Declaring type full name. pub type_name: String, /// Method name. pub method_name: String, /// Old signatures in this change. pub old_signatures: Vec, /// New signatures in this change. pub new_signatures: Vec, } /// Likely `MissingMethodException` compatibility risk. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MissingMethodRisk { /// Assembly that previously exposed the method. pub old_assembly: String, /// Declaring type full name. pub type_name: String, /// Method name. pub method_name: String, /// Old rendered method signature. pub old_signature: String, /// Risk reason. pub reason: String, } /// Managed API diff report. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ApiDiffReport { /// Old assembly summary. pub old_assembly: AssemblyDescriptor, /// New assembly summary. pub new_assembly: AssemblyDescriptor, /// Compared visibility scope. pub visibility: ApiVisibilityScope, /// Summary counts. pub summary: ApiDiffSummary, /// Removed types. pub removed_types: Vec, /// Added types. pub added_types: Vec, /// Removed methods. pub removed_methods: Vec, /// Added methods. pub added_methods: Vec, /// Changed method-name groups. pub signature_changed_methods: Vec, /// Likely `MissingMethodException` risks. pub missing_method_risks: Vec, } /// Resolved assembly reference output. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct AssemblyReferenceDescriptor { /// Referenced assembly name. pub name: String, /// Referenced version string. pub version: String, /// Referenced culture string, if present. pub culture: Option, /// Public key token hex string, if present. pub public_key_token: Option, /// Whether the reference resolved to a file path. pub resolved: bool, /// Resolved file path, if found. pub resolved_path: Option, /// Resolution source label. pub resolution_source: Option, /// Whether the reference looks like a framework assembly. pub is_framework_reference: bool, } /// Report for one assembly and its references. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct AssemblyReferenceReport { /// Assembly summary. pub assembly: AssemblyDescriptor, /// Reference rows. pub references: Vec, } #[derive(Debug)] struct ManagedAssembly { path: PathBuf, bytes: Vec, sections: Vec, metadata: Metadata, descriptor: AssemblyDescriptor, nested_parent_by_child: HashMap, } #[derive(Debug, Clone, Copy)] struct PeSectionSpan { virtual_address: usize, read_size: usize, raw_offset: usize, } /// Lists managed types for one or more assembly paths. /// /// # Errors /// /// Returns [`ManagedError`] when an assembly cannot be read or parsed. pub fn list_types( paths: &[PathBuf], query: &TypeQuery, ) -> Result, ManagedError> { let mut rows = Vec::new(); for path in paths { let assembly = ManagedAssembly::load(path)?; let Some(last_type_index) = u32::try_from(assembly.metadata.type_defs.len()).ok() else { return Ok(rows); }; for index in 1..=last_type_index { let descriptor = assembly.type_descriptor(index); if type_matches(&descriptor, query) { rows.push(descriptor); if query.limit.is_some_and(|limit| rows.len() >= limit) { return Ok(rows); } } } } Ok(rows) } /// Lists managed members for one or more assemblies and type names. /// /// # Errors /// /// Returns [`ManagedError`] when an assembly cannot be read or parsed. /// /// # Panics /// /// This function does not panic. pub fn list_members( assembly_paths: &[PathBuf], query: &MemberQuery, ) -> Result, ManagedError> { let mut rows = Vec::new(); for path in assembly_paths { let assembly = ManagedAssembly::load(path)?; for type_name in &query.type_names { if let Some(index) = assembly.find_type_index(type_name) { let mut members = assembly.member_descriptors(index, query); rows.append(&mut members); if query.limit.is_some_and(|limit| rows.len() >= limit) { if let Some(limit) = query.limit { rows.truncate(limit); } return Ok(rows); } } } } Ok(rows) } /// Inspects assembly references for one or more assemblies. /// /// # Errors /// /// Returns [`ManagedError`] when an assembly cannot be read or parsed. pub fn inspect_references( assembly_paths: &[PathBuf], query: &ReferenceQuery, ) -> Result, ManagedError> { let mut reports = Vec::new(); for path in assembly_paths { let assembly = ManagedAssembly::load(path)?; reports.push(assembly.reference_report(query)); } Ok(reports) } /// Builds a normalized managed API snapshot for one assembly. /// /// # Errors /// /// Returns [`ManagedError`] when the assembly cannot be read or parsed. pub fn snapshot_assembly_api( path: &Path, query: &ApiDiffQuery, ) -> Result { let assembly = ManagedAssembly::load(path)?; let mut types = Vec::new(); let mut methods = Vec::new(); let Some(last_type_index) = u32::try_from(assembly.metadata.type_defs.len()).ok() else { return Ok(AssemblyApiSnapshot { assembly: assembly.descriptor, types, methods, }); }; for index in 1..=last_type_index { let type_row = ApiTypeRow::from(assembly.type_descriptor(index)); if query.visibility.includes_type(&type_row.visibility) { methods.extend(assembly.api_method_rows(index, *query)); types.push(type_row); } } Ok(AssemblyApiSnapshot { assembly: assembly.descriptor, types, methods, }) } /// Diffs two managed assemblies after building API snapshots. /// /// # Errors /// /// Returns [`ManagedError`] when either assembly cannot be read or parsed. pub fn diff_assembly_api( old_path: &Path, new_path: &Path, query: &ApiDiffQuery, ) -> Result { let old = snapshot_assembly_api(old_path, query)?; let new = snapshot_assembly_api(new_path, query)?; Ok(diff_api_snapshots(old, new, query)) } /// Diffs two normalized managed API snapshots. #[must_use] pub fn diff_api_snapshots( old: AssemblyApiSnapshot, new: AssemblyApiSnapshot, query: &ApiDiffQuery, ) -> ApiDiffReport { let old_types = filtered_type_map(&old, query.visibility); let new_types = filtered_type_map(&new, query.visibility); let old_methods = filtered_method_groups(&old, query.visibility, &old_types); let new_methods = filtered_method_groups(&new, query.visibility, &new_types); let mut removed_types = old_types .iter() .filter(|(key, _)| !new_types.contains_key(*key)) .map(|(_, row)| ApiTypeChange::from(row)) .collect::>(); let mut added_types = new_types .iter() .filter(|(key, _)| !old_types.contains_key(*key)) .map(|(_, row)| ApiTypeChange::from(row)) .collect::>(); let mut removed_methods = Vec::new(); let mut added_methods = Vec::new(); let mut signature_changed_methods = Vec::new(); for (group, rows) in &old_methods { if !new_methods.contains_key(group) { removed_methods.extend(rows.iter().map(method_change_removed)); } } for (group, rows) in &new_methods { if !old_methods.contains_key(group) { added_methods.extend(rows.iter().map(method_change_added)); } } for (group, old_rows) in old_methods .iter() .filter(|(group, _)| new_methods.contains_key(*group)) { let new_rows = &new_methods[group]; let old_signatures = method_signature_map(old_rows); let new_signatures = method_signature_map(new_rows); if old_signatures.keys().eq(new_signatures.keys()) { continue; } signature_changed_methods.push(ApiMethodChange { type_name: group.0.clone(), method_name: group.1.clone(), old_signatures: old_signatures .values() .map(|row| row.signature.clone()) .collect(), new_signatures: new_signatures .values() .map(|row| row.signature.clone()) .collect(), }); for key in old_signatures .keys() .filter(|key| !new_signatures.contains_key(*key)) { if let Some(row) = old_signatures.get(key) { removed_methods.push(method_change_removed(row)); } } for key in new_signatures .keys() .filter(|key| !old_signatures.contains_key(*key)) { if let Some(row) = new_signatures.get(key) { added_methods.push(method_change_added(row)); } } } sort_type_changes(&mut removed_types); sort_type_changes(&mut added_types); sort_method_changes(&mut removed_methods); sort_method_changes(&mut added_methods); sort_method_changes(&mut signature_changed_methods); removed_methods.dedup(); added_methods.dedup(); let missing_method_risks = if query.include_missing_method_risks { missing_method_risks(&old.assembly, &removed_methods) } else { Vec::new() }; let summary = ApiDiffSummary { removed_types: removed_types.len(), added_types: added_types.len(), removed_methods: removed_methods.len(), added_methods: added_methods.len(), signature_changed_methods: signature_changed_methods.len(), missing_method_risks: missing_method_risks.len(), }; ApiDiffReport { old_assembly: old.assembly, new_assembly: new.assembly, visibility: query.visibility, summary, removed_types, added_types, removed_methods, added_methods, signature_changed_methods, missing_method_risks, } } impl ManagedAssembly { fn load(path: &Path) -> Result { let bytes = fs::read(path).map_err(|error| ManagedError::Read { path: path.to_path_buf(), message: error.to_string(), })?; let pe = PE::parse(&bytes).map_err(|_| ManagedError::NotPe { path: path.to_path_buf(), })?; let clr_data = pe.clr_data.ok_or_else(|| ManagedError::NotManaged { path: path.to_path_buf(), })?; let metadata_bytes = metadata_bytes(&bytes, &pe, clr_data.cor20_header.metadata) .ok_or_else(|| ManagedError::Metadata { path: path.to_path_buf(), message: "failed to map CLR metadata RVA into file bytes".to_string(), })?; let metadata = Metadata::parse(metadata_bytes).map_err(|error| ManagedError::Metadata { path: path.to_path_buf(), message: error.to_string(), })?; let assembly_info = metadata.assembly(); let assembly_name = assembly_info .as_ref() .map_or_else(|| file_stem(path), |info| info.name.clone()); let assembly_version = assembly_info.as_ref().map(AssemblyInfo::version_string); let public_key_token = assembly_info .as_ref() .and_then(AssemblyInfo::public_key_token_string); let descriptor = AssemblyDescriptor { path: path.to_path_buf(), assembly_name, assembly_version, runtime_version: metadata.version().to_string(), is_il_only: clr_data.cor20_header.is_il_only(), is_library: clr_data.cor20_header.is_il_library(), is_strong_name_signed: clr_data.cor20_header.is_strong_name_signed(), public_key_token, }; let file_alignment = pe .header .optional_header .map(|header| header.windows_fields.file_alignment) .ok_or_else(|| ManagedError::Metadata { path: path.to_path_buf(), message: "missing PE optional header".to_string(), })?; let sections = pe .sections .iter() .map(|section| PeSectionSpan { virtual_address: section.virtual_address as usize, read_size: section_read_size(section, file_alignment), raw_offset: section.pointer_to_raw_data as usize, }) .collect::>(); let nested_parent_by_child = metadata .nested_classes .iter() .map(|row| (row.nested_class, row.enclosing_class)) .collect::>(); Ok(Self { path: path.to_path_buf(), bytes, sections, metadata, descriptor, nested_parent_by_child, }) } fn bytes_at_rva(&self, rva: u32, size: usize) -> Option<&[u8]> { let offset = self.offset_for_rva(rva)?; self.bytes.get(offset..offset.checked_add(size)?) } fn offset_for_rva(&self, rva: u32) -> Option { let rva = rva as usize; self.sections.iter().find_map(|section| { let end = section.virtual_address.checked_add(section.read_size)?; if rva < section.virtual_address || rva >= end { return None; } section .raw_offset .checked_add(rva - section.virtual_address) }) } fn type_descriptor(&self, index: u32) -> TypeDescriptor { let row = self .metadata .get_type_def(index) .expect("type index should be in range"); let name = self.string_or_empty(row.type_name); let namespace = self.string_option(row.type_namespace); let full_name = self.full_type_name(index); let base_type = self .metadata .get_base_type(index) .map(|base| self.resolved_type_name(&base)); let interfaces = self .metadata .get_interfaces(index) .into_iter() .map(|item| self.resolved_type_name(&item)) .collect::>(); let visibility = type_visibility_label(row.flags).to_string(); let kind = self.type_kind(index).to_string(); TypeDescriptor { assembly_path: self.descriptor.path.clone(), assembly_name: self.descriptor.assembly_name.clone(), full_name, namespace, name, kind, visibility, is_public: is_public_type(row.flags), is_abstract: (row.flags & 0x0000_0080) != 0, is_sealed: (row.flags & 0x0000_0100) != 0, base_type, interfaces, } } fn find_type_index(&self, full_name: &str) -> Option { let last_type_index = u32::try_from(self.metadata.type_defs.len()).ok()?; (1..=last_type_index).find(|index| self.full_type_name(*index) == full_name) } fn member_descriptors(&self, type_index: u32, query: &MemberQuery) -> Vec { let type_name = self.full_type_name(type_index); let mut rows = Vec::new(); let include_methods = member_kind_enabled(query.kind.as_deref(), "method"); let include_fields = member_kind_enabled(query.kind.as_deref(), "field"); let include_properties = member_kind_enabled(query.kind.as_deref(), "property"); if include_methods { for item in self.method_descriptors(type_index, &type_name, query) { if member_matches(&item, query) { rows.push(item); if query.limit.is_some_and(|limit| rows.len() >= limit) { return rows; } } } } if include_fields { for item in self.field_descriptors(type_index, &type_name, query) { if member_matches(&item, query) { rows.push(item); if query.limit.is_some_and(|limit| rows.len() >= limit) { return rows; } } } } if include_properties { for item in self.property_descriptors(type_index, &type_name, query) { if member_matches(&item, query) { rows.push(item); if query.limit.is_some_and(|limit| rows.len() >= limit) { return rows; } } } } rows } fn api_method_rows(&self, type_index: u32, query: ApiDiffQuery) -> Vec { let type_name = self.full_type_name(type_index); self.method_descriptors( type_index, &type_name, &MemberQuery { kind: Some("method".to_string()), binding: BindingFilter { include_public: true, include_non_public: true, include_instance: true, include_static: true, }, include_special: query.include_special, ..MemberQuery::default() }, ) .into_iter() .filter_map(|member| match member { MemberDescriptor::Method { type_name, name, visibility, is_static, is_virtual, is_abstract, return_type, parameters, signature, .. } if query.visibility.includes_member(&visibility) => Some(ApiMethodRow { type_name, name, visibility, is_static, is_virtual, is_abstract, return_type, parameters, signature, }), _ => None, }) .collect() } fn method_descriptors( &self, type_index: u32, type_name: &str, query: &MemberQuery, ) -> Vec { self.metadata .get_type_methods(type_index) .into_iter() .filter_map(|(method_index, row)| { if !query.include_special && (row.flags & 0x0800) != 0 { return None; } if !query.binding.matches_method(row.flags) { return None; } let name = self.string_or_empty(row.name); let signature_bytes = self.metadata.blobs.get(row.signature).ok()?; let signature = MethodSig::parse_blob(signature_bytes).ok()?; let return_type = self.format_type_sig(&signature.return_type); let parameters = self.method_parameters(method_index, &signature); let signature_text = format_method_signature(&name, &return_type, ¶meters); Some(MemberDescriptor::Method { assembly_path: self.descriptor.path.clone(), assembly_name: self.descriptor.assembly_name.clone(), type_name: type_name.to_string(), name, visibility: member_visibility_label(row.flags).to_string(), is_static: (row.flags & 0x0010) != 0, is_virtual: (row.flags & 0x0040) != 0, is_abstract: (row.flags & 0x0400) != 0, return_type, parameters, signature: signature_text, }) }) .collect() } fn field_descriptors( &self, type_index: u32, type_name: &str, query: &MemberQuery, ) -> Vec { self.metadata .get_type_fields(type_index) .into_iter() .filter_map(|(_, row)| { if !query.binding.matches_field(row.flags) { return None; } let name = self.string_or_empty(row.name); let signature_bytes = self.metadata.blobs.get(row.signature).ok()?; let signature = FieldSig::parse_blob(signature_bytes).ok()?; let field_type = self.format_type_sig(&signature.field_type); let visibility = member_visibility_label(row.flags).to_string(); let signature_text = format_field_signature( &field_type, &name, &visibility, (row.flags & 0x0010) != 0, (row.flags & 0x0040) != 0, (row.flags & 0x0020) != 0, ); Some(MemberDescriptor::Field { assembly_path: self.descriptor.path.clone(), assembly_name: self.descriptor.assembly_name.clone(), type_name: type_name.to_string(), name, visibility, is_static: (row.flags & 0x0010) != 0, is_literal: (row.flags & 0x0040) != 0, is_init_only: (row.flags & 0x0020) != 0, field_type, signature: signature_text, }) }) .collect() } fn property_descriptors( &self, type_index: u32, type_name: &str, query: &MemberQuery, ) -> Vec { self.get_type_properties(type_index) .into_iter() .filter_map(|(property_index, row)| { let accessor_rows = self.property_accessors(property_index); if !query.binding.matches_property(&accessor_rows) { return None; } let name = self.string_or_empty(row.name); let signature_bytes = self.metadata.blobs.get(row.property_type).ok()?; let signature = PropertySig::parse_blob(signature_bytes).ok()?; let property_type = self.format_type_sig(&signature.property_type); let parameters = signature .params .iter() .map(|param| ParameterDescriptor { name: None, parameter_type: self.format_type_sig(param), }) .collect::>(); let getter_visibility = accessor_rows.iter().find_map(|item| { item.semantics .contains("getter") .then(|| item.visibility.clone()) }); let setter_visibility = accessor_rows.iter().find_map(|item| { item.semantics .contains("setter") .then(|| item.visibility.clone()) }); let is_static = accessor_rows.iter().any(|item| item.is_static); let visibility = getter_visibility .clone() .or_else(|| setter_visibility.clone()) .unwrap_or_else(|| "private".to_string()); let signature_text = format_property_signature( &property_type, &name, ¶meters, getter_visibility.as_deref(), setter_visibility.as_deref(), is_static, ); Some(MemberDescriptor::Property { assembly_path: self.descriptor.path.clone(), assembly_name: self.descriptor.assembly_name.clone(), type_name: type_name.to_string(), name, visibility, is_static, property_type, parameters, getter_visibility, setter_visibility, signature: signature_text, }) }) .collect() } fn reference_report(&self, query: &ReferenceQuery) -> AssemblyReferenceReport { let resolve_dirs = self .path .parent() .map(Path::to_path_buf) .into_iter() .chain(query.resolve_dirs.iter().cloned()) .collect::>(); let references = self .metadata .assembly_refs() .into_iter() .map(|item| resolve_reference(&item, &resolve_dirs)) .collect::>(); AssemblyReferenceReport { assembly: self.descriptor.clone(), references, } } fn full_type_name(&self, index: u32) -> String { let row = self .metadata .get_type_def(index) .expect("type index should be in range"); let name = self.string_or_empty(row.type_name); if let Some(parent) = self.nested_parent_by_child.get(&index) { return format!("{}+{}", self.full_type_name(*parent), name); } let namespace = self.string_option(row.type_namespace); if let Some(namespace) = namespace && !namespace.is_empty() { return format!("{namespace}.{name}"); } name } fn type_kind(&self, index: u32) -> &'static str { let row = self .metadata .get_type_def(index) .expect("type index should be in range"); if (row.flags & 0x20) != 0 { return "interface"; } match self .metadata .get_base_type(index) .map(|item| self.resolved_type_name(&item)) { Some(base) if base == "System.Enum" => "enum", Some(base) if base == "System.MulticastDelegate" => "delegate", Some(base) if base == "System.ValueType" => "struct", _ => "class", } } fn resolved_type_name(&self, resolved: &ResolvedType) -> String { match resolved { ResolvedType::TypeSpec { signature, .. } => { let signature_bytes = self.metadata.blobs.get(*signature).ok(); signature_bytes .and_then(parse_type_sig_blob) .map_or_else(|| resolved.full_name(), |sig| self.format_type_sig(&sig)) } _ => resolved.full_name(), } } fn format_type_sig(&self, signature: &TypeSig) -> String { match signature { TypeSig::Primitive(element) => element.name().to_string(), TypeSig::Class(token) | TypeSig::ValueType(token) => self .metadata .resolve_type(&CodedIndex::decode(CodedIndexKind::TypeDefOrRef, *token)) .map_or_else( || format!(""), |item| self.resolved_type_name(&item), ), TypeSig::SzArray(inner) => format!("{}[]", self.format_type_sig(inner)), TypeSig::Array { element_type, rank, .. } => { format!( "{}[{}]", self.format_type_sig(element_type), ",".repeat(rank.saturating_sub(1) as usize) ) } TypeSig::Ptr(inner) => format!("{}*", self.format_type_sig(inner)), TypeSig::ByRef(inner) => format!("{}&", self.format_type_sig(inner)), TypeSig::GenericInst { is_value_type: _, type_ref, type_args, } => { let base = self .metadata .resolve_type(&CodedIndex::decode(CodedIndexKind::TypeDefOrRef, *type_ref)) .map_or_else( || format!(""), |item| self.resolved_type_name(&item), ); let args = type_args .iter() .map(|item| self.format_type_sig(item)) .collect::>() .join(", "); format!("{base}<{args}>") } TypeSig::Var(index) => format!("!{index}"), TypeSig::MVar(index) => format!("!!{index}"), TypeSig::FnPtr(method) => format!("fnptr {}", self.format_method_sig(method, "invoke")), TypeSig::Modified { required, modifier, inner, } => { let label = if *required { "modreq" } else { "modopt" }; format!( "{label}() {}", self.format_type_sig(inner) ) } TypeSig::Pinned(inner) => format!("pinned {}", self.format_type_sig(inner)), _ => "".to_string(), } } fn format_method_sig(&self, signature: &MethodSig, name: &str) -> String { let return_type = self.format_type_sig(&signature.return_type); let parameters = signature .params .iter() .map(|item| ParameterDescriptor { name: None, parameter_type: self.format_type_sig(item), }) .collect::>(); format_method_signature(name, &return_type, ¶meters) } fn method_parameters( &self, method_index: u32, signature: &MethodSig, ) -> Vec { let param_rows = self.get_method_params(method_index); signature .params .iter() .enumerate() .map(|(index, item)| { let name = param_rows .iter() .find(|(_, row)| usize::from(row.sequence) == index + 1) .map(|(_, row)| self.string_or_empty(row.name)); ParameterDescriptor { name, parameter_type: self.format_type_sig(item), } }) .collect() } fn get_method_params(&self, method_index: u32) -> Vec<(u32, &clrmeta::ParamRow)> { let Some(row) = method_index .checked_sub(1) .and_then(|index| self.metadata.method_defs.get(index as usize)) else { return Vec::new(); }; let start = row.param_list; let end = method_index .checked_add(1) .and_then(|next_index| self.metadata.method_defs.get((next_index - 1) as usize)) .map_or_else( || length_index(&self.metadata.params), |item| item.param_list, ); ((start as usize)..(end as usize)) .filter_map(|index| { if index > 0 && index <= self.metadata.params.len() { Some((u32::try_from(index).ok()?, &self.metadata.params[index - 1])) } else { None } }) .collect() } fn get_type_properties(&self, type_index: u32) -> Vec<(u32, &clrmeta::PropertyRow)> { let Some(start) = self .metadata .property_maps .iter() .find(|row| row.parent == type_index) .map(|row| row.property_list) else { return Vec::new(); }; let end = self .metadata .property_maps .iter() .filter(|row| row.parent > type_index) .map(|row| row.property_list) .min() .unwrap_or_else(|| length_index(&self.metadata.properties)); ((start as usize)..(end as usize)) .filter_map(|index| { if index > 0 && index <= self.metadata.properties.len() { Some(( u32::try_from(index).ok()?, &self.metadata.properties[index - 1], )) } else { None } }) .collect() } fn property_accessors(&self, property_index: u32) -> Vec { self.metadata .method_semantics .iter() .filter(|row| { row.association.table == Some(TableId::Property) && row.association.row == property_index }) .filter_map(|row| { let method = row .method .checked_sub(1) .and_then(|index| self.metadata.method_defs.get(index as usize))?; Some(PropertyAccessor { visibility: member_visibility_label(method.flags).to_string(), is_static: (method.flags & 0x0010) != 0, semantics: method_semantics_label(row.semantics), }) }) .collect() } fn string_or_empty(&self, index: u32) -> String { self.metadata .strings .get(index) .map_or_else(|_| String::new(), ToString::to_string) } fn string_option(&self, index: u32) -> Option { if index == 0 { return None; } self.metadata .strings .get(index) .ok() .map(ToString::to_string) } } #[derive(Debug, Clone)] struct PropertyAccessor { visibility: String, is_static: bool, semantics: String, } fn resolve_reference( reference: &AssemblyRefInfo, resolve_dirs: &[PathBuf], ) -> AssemblyReferenceDescriptor { let resolved = resolve_dirs.iter().find_map(|directory| { let dll_path = directory.join(format!("{}.dll", reference.name)); if dll_path.is_file() { return Some((dll_path, "resolve_dir".to_string())); } let exe_path = directory.join(format!("{}.exe", reference.name)); exe_path .is_file() .then(|| (exe_path, "resolve_dir".to_string())) }); let (resolved_path, resolution_source, resolved_flag) = match resolved { Some((path, source)) => (Some(path), Some(source), true), None => (None, None, false), }; AssemblyReferenceDescriptor { name: reference.name.clone(), version: reference.version_string(), culture: reference.culture.clone(), public_key_token: reference .public_key_token .as_ref() .map(|value| bytes_to_hex(value)), resolved: resolved_flag, resolved_path, resolution_source, is_framework_reference: is_framework_reference(&reference.name), } } fn type_matches(descriptor: &TypeDescriptor, query: &TypeQuery) -> bool { if query.public_only && !descriptor.is_public { return false; } if let Some(kind) = &query.kind && descriptor.kind != *kind { return false; } if let Some(pattern) = &query.match_pattern && !pattern.is_match(&descriptor.full_name) { return false; } if let Some(pattern) = &query.namespace_pattern { let namespace = descriptor.namespace.as_deref().unwrap_or_default(); if !pattern.is_match(namespace) { return false; } } if let Some(pattern) = &query.base_pattern { let base = descriptor.base_type.as_deref().unwrap_or_default(); if !pattern.is_match(base) { return false; } } if let Some(pattern) = &query.interface_pattern && !descriptor .interfaces .iter() .any(|item| pattern.is_match(item)) { return false; } true } fn member_matches(descriptor: &MemberDescriptor, query: &MemberQuery) -> bool { let name = match descriptor { MemberDescriptor::Method { name, .. } | MemberDescriptor::Field { name, .. } | MemberDescriptor::Property { name, .. } => name, }; if query.user_code_only && is_compiler_generated_member_name(name) { return false; } if let Some(pattern) = &query.match_pattern { if !pattern.is_match(name) { return false; } } true } impl From for ApiTypeRow { fn from(value: TypeDescriptor) -> Self { Self { full_name: value.full_name, namespace: value.namespace, name: value.name, kind: value.kind, visibility: value.visibility, is_public: value.is_public, } } } impl From<&ApiTypeRow> for ApiTypeChange { fn from(value: &ApiTypeRow) -> Self { Self { type_name: value.full_name.clone(), kind: value.kind.clone(), visibility: value.visibility.clone(), } } } fn filtered_type_map( snapshot: &AssemblyApiSnapshot, visibility: ApiVisibilityScope, ) -> BTreeMap { snapshot .types .iter() .filter(|row| visibility.includes_type(&row.visibility)) .map(|row| (row.full_name.clone(), row.clone())) .collect() } fn filtered_method_groups( snapshot: &AssemblyApiSnapshot, visibility: ApiVisibilityScope, types: &BTreeMap, ) -> BTreeMap<(String, String), Vec> { let mut groups: BTreeMap<(String, String), Vec> = BTreeMap::new(); for row in &snapshot.methods { if types.contains_key(&row.type_name) && visibility.includes_member(&row.visibility) { groups .entry((row.type_name.clone(), row.name.clone())) .or_default() .push(row.clone()); } } for rows in groups.values_mut() { rows.sort_by(|left, right| method_sort_key(left).cmp(&method_sort_key(right))); } groups } fn method_signature_map(rows: &[ApiMethodRow]) -> BTreeMap { rows.iter() .map(|row| (method_fingerprint(row), row.clone())) .collect() } fn method_fingerprint(row: &ApiMethodRow) -> String { let parameters = row .parameters .iter() .map(|parameter| parameter.parameter_type.as_str()) .collect::>() .join("\u{1f}"); format!( "static={} return={} params={}", row.is_static, row.return_type, parameters ) } fn method_change_removed(row: &ApiMethodRow) -> ApiMethodChange { ApiMethodChange { type_name: row.type_name.clone(), method_name: row.name.clone(), old_signatures: vec![row.signature.clone()], new_signatures: Vec::new(), } } fn method_change_added(row: &ApiMethodRow) -> ApiMethodChange { ApiMethodChange { type_name: row.type_name.clone(), method_name: row.name.clone(), old_signatures: Vec::new(), new_signatures: vec![row.signature.clone()], } } fn missing_method_risks( old_assembly: &AssemblyDescriptor, removed_methods: &[ApiMethodChange], ) -> Vec { let mut rows = removed_methods .iter() .flat_map(|change| { change .old_signatures .iter() .map(|signature| MissingMethodRisk { old_assembly: old_assembly.assembly_name.clone(), type_name: change.type_name.clone(), method_name: change.method_name.clone(), old_signature: signature.clone(), reason: "removed_or_changed_public_signature".to_string(), }) }) .collect::>(); rows.sort_by(|left, right| { ( left.type_name.as_str(), left.method_name.as_str(), left.old_signature.as_str(), ) .cmp(&( right.type_name.as_str(), right.method_name.as_str(), right.old_signature.as_str(), )) }); rows.dedup(); rows } fn sort_type_changes(rows: &mut [ApiTypeChange]) { rows.sort_by(|left, right| left.type_name.cmp(&right.type_name)); } fn sort_method_changes(rows: &mut [ApiMethodChange]) { rows.sort_by(|left, right| { ( left.type_name.as_str(), left.method_name.as_str(), left.old_signatures.first().map_or("", String::as_str), left.new_signatures.first().map_or("", String::as_str), ) .cmp(&( right.type_name.as_str(), right.method_name.as_str(), right.old_signatures.first().map_or("", String::as_str), right.new_signatures.first().map_or("", String::as_str), )) }); } fn method_sort_key(row: &ApiMethodRow) -> (&str, &str, bool, String) { ( row.type_name.as_str(), row.name.as_str(), row.is_static, row.parameters .iter() .map(|parameter| parameter.parameter_type.as_str()) .collect::>() .join("\u{1f}"), ) } fn is_compiler_generated_member_name(name: &str) -> bool { name.starts_with('<') || name.starts_with("<>") || name.starts_with("CS$<") || name.contains(">k__BackingField") } fn member_kind_enabled(kind: Option<&str>, label: &str) -> bool { match kind { None | Some("all") => true, Some(value) => value == label, } } const fn is_public_type(flags: u32) -> bool { matches!(flags & 0x0000_0007, 0x0000_0001 | 0x0000_0002) } const fn type_visibility_label(flags: u32) -> &'static str { match flags & 0x0000_0007 { 0x0000_0001 => "public", 0x0000_0002 => "nested_public", 0x0000_0003 => "nested_private", 0x0000_0004 => "nested_family", 0x0000_0005 => "nested_assembly", 0x0000_0006 => "nested_fam_and_assem", 0x0000_0007 => "nested_fam_or_assem", _ => "not_public", } } const fn member_visibility_label(flags: u16) -> &'static str { match flags & 0x0007 { 0x0001 => "private", 0x0002 => "fam_and_assem", 0x0003 => "assembly", 0x0004 => "family", 0x0005 => "fam_or_assem", 0x0006 => "public", _ => "compiler_controlled", } } fn method_semantics_label(flags: u16) -> String { let mut parts = Vec::new(); if (flags & 0x0001) != 0 { parts.push("setter"); } if (flags & 0x0002) != 0 { parts.push("getter"); } if (flags & 0x0004) != 0 { parts.push("other"); } if (flags & 0x0008) != 0 { parts.push("add_on"); } if (flags & 0x0010) != 0 { parts.push("remove_on"); } if (flags & 0x0020) != 0 { parts.push("fire"); } if parts.is_empty() { "other".to_string() } else { parts.join("|") } } fn format_method_signature( name: &str, return_type: &str, parameters: &[ParameterDescriptor], ) -> String { let args = parameters .iter() .map(|item| { item.name.as_ref().map_or_else( || item.parameter_type.clone(), |name| format!("{} {name}", item.parameter_type), ) }) .collect::>() .join(", "); format!("{return_type} {name}({args})") } fn format_field_signature( field_type: &str, name: &str, visibility: &str, is_static: bool, is_literal: bool, is_init_only: bool, ) -> String { let mut prefixes = Vec::new(); prefixes.push(visibility.to_string()); if is_static { prefixes.push("static".to_string()); } if is_literal { prefixes.push("literal".to_string()); } if is_init_only { prefixes.push("initonly".to_string()); } format!("{} {field_type} {name}", prefixes.join(" ")) } fn format_property_signature( property_type: &str, name: &str, parameters: &[ParameterDescriptor], getter_visibility: Option<&str>, setter_visibility: Option<&str>, is_static: bool, ) -> String { let property_name = if parameters.is_empty() { name.to_string() } else { let args = parameters .iter() .map(|item| item.parameter_type.clone()) .collect::>() .join(", "); format!("{name}[{args}]") }; let mut accessors = Vec::new(); if let Some(visibility) = getter_visibility { accessors.push(accessor_signature("get", visibility)); } if let Some(visibility) = setter_visibility { accessors.push(accessor_signature("set", visibility)); } let static_prefix = if is_static { "static " } else { "" }; format!( "{static_prefix}{property_type} {property_name} {{ {} }}", accessors.join(" ") ) } fn accessor_signature(name: &str, visibility: &str) -> String { if visibility == "public" { format!("{name};") } else { format!("{visibility} {name};") } } fn file_stem(path: &Path) -> String { path.file_stem() .and_then(|value| value.to_str()) .map_or_else(|| path.display().to_string(), ToString::to_string) } fn is_framework_reference(name: &str) -> bool { name == "mscorlib" || name == "netstandard" || name.starts_with("System") || name.starts_with("Microsoft.") } fn bytes_to_hex(bytes: &[u8]) -> String { let mut text = String::with_capacity(bytes.len() * 2); for byte in bytes { let _ = write!(text, "{byte:02x}"); } text } fn parse_type_sig_blob(bytes: &[u8]) -> Option { let mut reader = Reader::new(bytes); TypeSig::parse(&mut reader).ok() } fn metadata_bytes<'a>( bytes: &'a [u8], pe: &PE<'_>, directory: goblin::pe::data_directories::DataDirectory, ) -> Option<&'a [u8]> { let file_alignment = pe .header .optional_header .map(|header| header.windows_fields.file_alignment)?; let offset = find_pe_offset( directory.virtual_address as usize, &pe.sections, file_alignment, )?; let size = directory.size as usize; bytes.get(offset..offset.checked_add(size)?) } fn find_pe_offset(rva: usize, sections: &[SectionTable], file_alignment: u32) -> Option { sections.iter().find_map(|section| { let start = section.virtual_address as usize; let read_size = section_read_size(section, file_alignment); let end = start.checked_add(read_size)?; if rva < start || rva >= end { return None; } let raw = section.pointer_to_raw_data as usize; raw.checked_add(rva - start) }) } fn section_read_size(section: &SectionTable, file_alignment: u32) -> usize { let raw_size = section.size_of_raw_data as usize; let virtual_size = section.virtual_size as usize; if file_alignment < 0x200 { return raw_size.max(virtual_size); } if virtual_size == 0 { return raw_size; } raw_size.max(virtual_size) } fn length_index(items: &[T]) -> u32 { u32::try_from(items.len().saturating_add(1)).unwrap_or(u32::MAX) } impl BindingFilter { /// Returns true when the method matches the filter. #[must_use] pub fn matches_method(self, flags: u16) -> bool { self.matches_common(flags) } /// Returns true when the field matches the filter. #[must_use] pub fn matches_field(self, flags: u16) -> bool { self.matches_common(flags) } /// Returns true when any accessor of a property matches the filter. #[must_use] fn matches_property(self, accessors: &[PropertyAccessor]) -> bool { accessors.iter().any(|item| { self.matches_visibility(&item.visibility) && self.matches_scope(item.is_static) }) } fn matches_common(self, flags: u16) -> bool { self.matches_visibility(member_visibility_label(flags)) && self.matches_scope((flags & 0x0010) != 0) } fn matches_visibility(self, visibility: &str) -> bool { let is_public = visibility == "public"; (is_public && self.include_public) || (!is_public && self.include_non_public) } const fn matches_scope(self, is_static: bool) -> bool { (is_static && self.include_static) || (!is_static && self.include_instance) } } #[cfg(test)] mod tests { use std::path::PathBuf; use regex_lite::Regex; use super::*; fn workspace_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") .join("..") .canonicalize() .expect("workspace root") } fn managed_fixture_paths() -> Vec { let root = workspace_root() .join("fixtures") .join("managed") .join("bin"); vec![root.join("GameAssembly.dll")] } fn fixture_support_dir() -> PathBuf { workspace_root() .join("fixtures") .join("managed") .join("bin") } fn diagnose_fixture_root() -> PathBuf { workspace_root() .join("fixtures") .join("managed") .join("diagnose-bin") .join("root") .join("RootPlugin.dll") } fn diagnose_server_a_dir() -> PathBuf { workspace_root() .join("fixtures") .join("managed") .join("diagnose-bin") .join("server-a") } fn diagnose_server_b_dir() -> PathBuf { workspace_root() .join("fixtures") .join("managed") .join("diagnose-bin") .join("server-b") } #[test] fn binding_filter_matches_expected_visibility_and_scope() { let filter = BindingFilter { include_public: true, include_non_public: false, include_instance: true, include_static: false, }; assert!(filter.matches_method(0x0006)); assert!(!filter.matches_method(0x0016)); assert!(!filter.matches_method(0x0001)); } #[test] fn formatting_helpers_render_compact_signatures() { let parameters = vec![ ParameterDescriptor { name: Some("count".to_string()), parameter_type: "int".to_string(), }, ParameterDescriptor { name: Some("tag".to_string()), parameter_type: "string".to_string(), }, ]; assert_eq!( format_method_signature("StartProject", "void", ¶meters), "void StartProject(int count, string tag)" ); assert_eq!( format_field_signature("int", "_buildTicks", "private", false, false, true), "private initonly int _buildTicks" ); assert_eq!( format_property_signature( "string", "ProjectName", &[], Some("public"), Some("private"), false ), "string ProjectName { get; private set; }" ); } #[test] fn type_and_member_label_helpers_are_stable() { assert_eq!(type_visibility_label(0x0000_0001), "public"); assert_eq!(type_visibility_label(0x0000_0003), "nested_private"); assert_eq!(member_visibility_label(0x0006), "public"); assert_eq!(member_visibility_label(0x0001), "private"); assert_eq!(method_semantics_label(0x0003), "setter|getter"); assert!(is_framework_reference("System.Runtime")); assert!(!is_framework_reference("FixtureSupport")); } #[test] fn list_types_filters_by_name_kind_namespace_and_limit() { let paths = managed_fixture_paths(); let broad_query = TypeQuery { match_pattern: Some(Regex::new("(?i)spacecraft").expect("regex")), namespace_pattern: None, kind: None, public_only: false, base_pattern: None, interface_pattern: None, limit: None, }; let broad_types = list_types(&paths, &broad_query).expect("managed types"); assert!(broad_types.iter().any(|item| { item.full_name == "Game.UI.Windows.Windows.SpaceCraftConstructionWindow" && item.kind == "class" })); assert!(broad_types.iter().any(|item| { item.full_name == "Data.SpacecraftConstructData" && item.kind == "struct" })); let filtered_query = TypeQuery { match_pattern: Some(Regex::new("(?i)spacecraft").expect("regex")), namespace_pattern: Some(Regex::new(r"(?i)^Game\.UI").expect("regex")), kind: Some("class".to_string()), public_only: true, base_pattern: Some( Regex::new("(?i)MercuryFixture.Support.LaunchVehicleBase").expect("regex"), ), interface_pattern: Some( Regex::new("(?i)MercuryFixture.Support.ILaunchable").expect("regex"), ), limit: Some(1), }; let filtered = list_types(&paths, &filtered_query).expect("filtered managed types"); assert_eq!(filtered.len(), 1); assert_eq!( filtered[0].full_name, "Game.UI.Windows.Windows.SpaceCraftConstructionWindow" ); } #[test] fn list_members_covers_kind_binding_and_special_name_filters() { let paths = managed_fixture_paths(); let type_name = "Game.UI.Windows.Windows.SpaceCraftConstructionWindow".to_string(); let all_members = list_members( &paths, &MemberQuery { type_names: vec![type_name.clone()], kind: None, match_pattern: Some( Regex::new("(?i)build|project|launch|queue|complete|projectname") .expect("regex"), ), binding: BindingFilter { include_public: true, include_non_public: true, include_instance: true, include_static: true, }, include_special: false, user_code_only: false, limit: None, }, ) .expect("all members"); assert!(all_members.iter().any(|item| matches!( item, MemberDescriptor::Method { name, .. } if name == "StartProject" ))); assert!(all_members.iter().any(|item| matches!( item, MemberDescriptor::Field { name, .. } if name == "_buildTicks" ))); assert!(all_members.iter().any(|item| matches!( item, MemberDescriptor::Property { name, .. } if name == "ProjectName" ))); assert!(!all_members.iter().any(|item| matches!( item, MemberDescriptor::Method { name, .. } if name == "get_ProjectName" ))); let special_members = list_members( &paths, &MemberQuery { type_names: vec![type_name], kind: Some("method".to_string()), match_pattern: Some(Regex::new("(?i)projectname").expect("regex")), binding: BindingFilter { include_public: true, include_non_public: true, include_instance: true, include_static: false, }, include_special: true, user_code_only: false, limit: None, }, ) .expect("special members"); assert!(special_members.iter().any(|item| matches!( item, MemberDescriptor::Method { name, .. } if name == "get_ProjectName" ))); let filtered_members = list_members( &paths, &MemberQuery { type_names: vec![ "Game.UI.Windows.Windows.SpaceCraftConstructionWindow".to_string(), ], kind: Some("field".to_string()), match_pattern: Some(Regex::new("(?i)projectname").expect("regex")), binding: BindingFilter { include_public: true, include_non_public: true, include_instance: true, include_static: true, }, include_special: false, user_code_only: true, limit: None, }, ) .expect("filtered members"); assert!(!filtered_members.iter().any(|item| matches!( item, MemberDescriptor::Field { name, .. } if name.contains("BackingField") ))); } #[test] fn inspect_references_resolves_fixture_support_dependency() { let paths = managed_fixture_paths(); let reports = inspect_references( &paths, &ReferenceQuery { resolve_dirs: vec![fixture_support_dir()], }, ) .expect("reference reports"); assert_eq!(reports.len(), 1); let fixture_support = reports[0] .references .iter() .find(|item| item.name == "FixtureSupport") .expect("FixtureSupport reference"); assert!(fixture_support.resolved); assert!( fixture_support .resolved_path .as_ref() .is_some_and(|path| path.ends_with("FixtureSupport.dll")) ); } fn api_type(full_name: &str, visibility: &str, is_public: bool) -> ApiTypeRow { ApiTypeRow { full_name: full_name.to_string(), namespace: full_name .rsplit_once('.') .map(|(namespace, _)| namespace.to_string()), name: full_name .rsplit_once('.') .map_or(full_name, |(_, name)| name) .to_string(), kind: "class".to_string(), visibility: visibility.to_string(), is_public, } } fn api_method( type_name: &str, name: &str, visibility: &str, is_static: bool, return_type: &str, parameters: &[&str], ) -> ApiMethodRow { let parameters = parameters .iter() .map(|parameter_type| ParameterDescriptor { name: None, parameter_type: (*parameter_type).to_string(), }) .collect::>(); ApiMethodRow { type_name: type_name.to_string(), name: name.to_string(), visibility: visibility.to_string(), is_static, is_virtual: false, is_abstract: false, return_type: return_type.to_string(), parameters: parameters.clone(), signature: format_method_signature(name, return_type, ¶meters), } } fn snapshot(types: Vec, methods: Vec) -> AssemblyApiSnapshot { AssemblyApiSnapshot { assembly: AssemblyDescriptor { path: PathBuf::from("Fixture.dll"), assembly_name: "Fixture".to_string(), assembly_version: Some("1.0.0.0".to_string()), runtime_version: "v4.0.30319".to_string(), is_il_only: true, is_library: true, is_strong_name_signed: false, public_key_token: None, }, types, methods, } } #[test] fn diff_snapshots_reports_type_and_method_breaks() { let old = snapshot( vec![api_type("Game.Api.OldType", "public", true)], vec![api_method( "Game.Api.OldType", "Launch", "public", false, "void", &["string"], )], ); let new = snapshot( vec![api_type("Game.Api.NewType", "public", true)], Vec::new(), ); let report = diff_api_snapshots( old, new, &ApiDiffQuery { visibility: ApiVisibilityScope::Public, include_special: false, include_missing_method_risks: true, }, ); assert_eq!(report.summary.removed_types, 1); assert_eq!(report.summary.added_types, 1); assert_eq!(report.summary.removed_methods, 1); assert_eq!(report.summary.missing_method_risks, 1); assert_eq!(report.removed_types[0].type_name, "Game.Api.OldType"); assert_eq!(report.added_types[0].type_name, "Game.Api.NewType"); assert_eq!( report.missing_method_risks[0].reason, "removed_or_changed_public_signature" ); } #[test] fn diff_snapshots_reports_name_group_signature_changes_and_added_overloads() { let old = snapshot( vec![api_type("Game.Api.Rocket", "public", true)], vec![api_method( "Game.Api.Rocket", "Launch", "public", false, "void", &["string"], )], ); let new = snapshot( vec![api_type("Game.Api.Rocket", "public", true)], vec![ api_method( "Game.Api.Rocket", "Launch", "public", false, "void", &["int"], ), api_method( "Game.Api.Rocket", "Launch", "public", false, "void", &["int", "bool"], ), ], ); let report = diff_api_snapshots(old, new, &ApiDiffQuery::default()); assert_eq!(report.summary.signature_changed_methods, 1); assert_eq!(report.summary.added_methods, 2); assert_eq!(report.summary.removed_methods, 1); assert_eq!( report.signature_changed_methods[0].type_name, "Game.Api.Rocket" ); assert_eq!(report.signature_changed_methods[0].method_name, "Launch"); assert_eq!(report.missing_method_risks.len(), 1); } #[test] fn api_visibility_scope_filters_rows() { let old = snapshot( vec![ api_type("Game.Api.PublicType", "public", true), api_type("Game.Api.InternalType", "not_public", false), api_type("Game.Api.PrivateType", "nested_private", false), ], vec![ api_method( "Game.Api.PublicType", "Public", "public", false, "void", &[], ), api_method( "Game.Api.InternalType", "Internal", "assembly", false, "void", &[], ), api_method( "Game.Api.PrivateType", "Private", "private", false, "void", &[], ), ], ); let new = snapshot(Vec::new(), Vec::new()); let public = diff_api_snapshots( old.clone(), new.clone(), &ApiDiffQuery { visibility: ApiVisibilityScope::Public, ..ApiDiffQuery::default() }, ); let internal = diff_api_snapshots( old.clone(), new.clone(), &ApiDiffQuery { visibility: ApiVisibilityScope::Internal, ..ApiDiffQuery::default() }, ); let all = diff_api_snapshots( old, new, &ApiDiffQuery { visibility: ApiVisibilityScope::All, ..ApiDiffQuery::default() }, ); assert_eq!(public.summary.removed_methods, 1); assert_eq!(internal.summary.removed_methods, 2); assert_eq!(all.summary.removed_methods, 3); } #[test] fn diagnose_dependencies_reports_closure_risks_and_winners() { let report = diagnose_dependencies( &[diagnose_fixture_root()], &DiagnoseQuery { resolve_dirs: vec![diagnose_server_a_dir(), diagnose_server_b_dir()], test_only_patterns: Vec::new(), use_default_test_patterns: true, }, ) .expect("diagnosis report"); assert_eq!(report.summary.root_count, 1); assert!(report.summary.error_count > 0); assert!(report.references.iter().any(|entry| { entry.reference_name == "MissingOnly" && entry.resolution_status == ResolutionStatus::Missing })); assert!( report .conflicts .iter() .any(|entry| entry.reference_name == "RuntimeDependency") ); assert!(report.winners.iter().any(|entry| { entry.reference_name == "0Harmony" && entry.winner.assembly.assembly_version.as_deref() == Some("2.2.2.0") })); assert!( report .test_only .iter() .any(|entry| entry.assembly.assembly_name == "TestOnlySupport") ); for expected in [ "missing_reference", "version_mismatch", "test_only_dependency", "missing_method", "missing_type", ] { assert!( report.risks.iter().any(|entry| entry.kind == expected), "expected risk kind {expected} in {:#?}", report.risks ); } } #[test] fn diagnose_dependencies_allows_disabling_default_test_only_patterns() { let report = diagnose_dependencies( &[diagnose_fixture_root()], &DiagnoseQuery { resolve_dirs: vec![diagnose_server_a_dir(), diagnose_server_b_dir()], test_only_patterns: Vec::new(), use_default_test_patterns: false, }, ) .expect("diagnosis report"); assert!( !report .test_only .iter() .any(|entry| entry.assembly.assembly_name == "TestOnlySupport") ); assert!( !report .risks .iter() .any(|entry| entry.kind == "test_only_dependency") ); } #[test] fn diagnose_dependencies_accepts_explicit_test_only_regex_patterns() { let report = diagnose_dependencies( &[diagnose_fixture_root()], &DiagnoseQuery { resolve_dirs: vec![diagnose_server_a_dir(), diagnose_server_b_dir()], test_only_patterns: vec!["TestOnly(Support|Fixture)".to_string()], use_default_test_patterns: false, }, ) .expect("diagnosis report"); assert!( report .test_only .iter() .any(|entry| entry.assembly.assembly_name == "TestOnlySupport") ); assert!( report .risks .iter() .any(|entry| entry.kind == "test_only_dependency") ); } }