Files
MetaCrate/tools/ci-matrix/src/api_surface.rs
Chili Palmer c9a1170a27
Some checks failed
API and SemVer surface / api-surface (push) Failing after 1m34s
Native code generation / deterministic (push) Has been cancelled
Concurrency and resource soak audit / soak (push) Has been cancelled
Documentation / documentation (push) Has been cancelled
performance evidence / audit (push) Has been cancelled
First release candidate / non-fuzz-release-gate (push) Has been cancelled
Release platform and feature matrix / audit (push) Has been cancelled
Release platform and feature matrix / matrix (false, linux-stable-minimal, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, macos-stable-portable, x86_64-apple-darwin, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, windows-stable-portable, x86_64-pc-windows-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-msrv-portable, x86_64-unknown-linux-gnu, 1.96.0) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-default, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-features, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-release-surface, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Dependency and supply-chain audit / audit (push) Has been cancelled
Native release artifact audit / audit (push) Has been cancelled
Imaging and meshing gate / native (push) Failing after 53s
JPEG 2000 feature / linux (push) Successful in 2m49s
Native Rust workspace compile / compile (push) Failing after 59s
Skia feature / linux (push) Has been cancelled
Complete first release candidate audit (#107)
2026-08-12 14:44:28 +00:00

1339 lines
47 KiB
Rust

use super::{MatrixError, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::fs::{self, OpenOptions};
use std::io::Write as _;
use std::path::{Path, PathBuf};
const BASELINE_PATH: &str = "api/SEMVER-BASELINE.json";
const REPORT_PATH: &str = "api/SEMVER-AUDIT.md";
const MEMBER_PATH: &str = "api/RUST-MAPPING.tsv";
const TYPE_PATH: &str = "api/RUST-TYPES.tsv";
const UPSTREAM_COMMIT: &str = "2aa70bb68513b39795da5d13c88f31b86e85a3ba";
const PUBLIC_CRATES: [(&str, &str); 15] = [
("libremetaverse", "crates/libremetaverse"),
("libremetaverse-types", "crates/libremetaverse-types"),
(
"libremetaverse-structured-data",
"crates/libremetaverse-structured-data",
),
("libremetaverse-imaging", "crates/libremetaverse-imaging"),
(
"libremetaverse-imaging-skia",
"crates/libremetaverse-imaging-skia",
),
(
"libremetaverse-prim-mesher",
"crates/libremetaverse-prim-mesher",
),
(
"libremetaverse-rendering-simple",
"crates/libremetaverse-rendering-simple",
),
(
"libremetaverse-rendering-mesh-foundry",
"crates/libremetaverse-rendering-mesh-foundry",
),
(
"libremetaverse-lsl-tools",
"crates/libremetaverse-lsl-tools",
),
("libremetaverse-rlv", "crates/libremetaverse-rlv"),
(
"libremetaverse-utilities",
"crates/libremetaverse-utilities",
),
(
"libremetaverse-voice-vivox",
"crates/libremetaverse-voice-vivox",
),
(
"libremetaverse-voice-webrtc",
"crates/libremetaverse-voice-webrtc",
),
("libremetaverse-openjpeg", "crates/libremetaverse-openjpeg"),
("libremetaverse-opus", "crates/libremetaverse-opus"),
];
const REQUIRED_NON_EXHAUSTIVE_ERRORS: [(&str, &str); 7] = [
("crates/libremetaverse-types/src/shim.rs", "Error"),
("crates/libremetaverse-openjpeg/src/lib.rs", "Error"),
(
"crates/libremetaverse-voice-webrtc/src/native.rs",
"WebRtcError",
),
(
"crates/libremetaverse/src/client_core.rs",
"ClientCoreError",
),
(
"crates/libremetaverse/src/udp_transport.rs",
"UdpTransportError",
),
(
"crates/libremetaverse-voice-vivox/src/protocol.rs",
"VivoxError",
),
(
"crates/libremetaverse-rlv/src/protocol.rs",
"RlvParseErrorKind",
),
];
const REQUIRED_THREAD_SAFE_TRAITS: [(&str, &str); 6] = [
(
"crates/libremetaverse/src/generated.rs",
"pub trait IBakingTextureProvider: std::any::Any + Send + Sync",
),
(
"crates/libremetaverse/src/generated.rs",
"pub trait IGridClient: Send + Sync",
),
(
"crates/libremetaverse/src/generated.rs",
"pub trait ICurrentOutfitPolicy: Send + Sync",
),
(
"crates/libremetaverse-rlv/src/generated.rs",
"pub trait IRlvActionCallbacks: Send + Sync",
),
(
"crates/libremetaverse-rlv/src/generated.rs",
"pub trait IRlvQueryCallbacks: Send + Sync",
),
(
"crates/libremetaverse-voice-webrtc/src/compatibility.rs",
"pub trait IVoiceLogger: Send + Sync",
),
];
const OWNERSHIP_TERMS: [&str; 14] = [
"none",
"owned_value",
"shared_self",
"shared_or_mutable_self",
"mutable_self",
"owned",
"optional_owned",
"mutable_borrow",
"optional_mutable_borrow",
"mutable_output",
"shared_borrow",
"optional_shared",
"shared",
"stored_callback",
];
type Row = BTreeMap<String, String>;
#[derive(Debug)]
struct QualityCounts {
intentional_differences: usize,
descriptive_overloads: usize,
async_functions: usize,
send_boxed_futures: usize,
thread_safe_callbacks: usize,
enum_types: usize,
bitflag_types: usize,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
struct ApiBaseline {
schema: u32,
release: String,
upstream_commit: String,
mapped_public_types: usize,
mapped_members: usize,
external_signature_types: usize,
support_traits: usize,
intentional_differences: usize,
descriptive_overloads: usize,
async_functions: usize,
send_boxed_futures: usize,
thread_safe_callbacks: usize,
thread_safe_dyn_traits: usize,
non_exhaustive_error_enums: usize,
enum_types: usize,
bitflag_types: usize,
unexplained_mappings: usize,
accidental_public_dependency_types: usize,
type_mapping_sha256: String,
member_mapping_sha256: String,
exported_mapping_sha256: String,
rust_declaration_sha256: String,
crates: Vec<CrateSurface>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
struct CrateSurface {
name: String,
version: String,
features: BTreeMap<String, Vec<String>>,
mapped_types: usize,
mapped_members: usize,
public_declarations: usize,
declaration_sha256: String,
}
#[derive(Debug, Serialize)]
struct ApiEvidence<'a> {
schema: u32,
release: &'a str,
upstream_commit: &'a str,
baseline_sha256: String,
mapped_public_types: usize,
mapped_members: usize,
intentional_differences: usize,
unexplained_mappings: usize,
accidental_public_dependency_types: usize,
thread_safe_dyn_traits: usize,
non_exhaustive_error_enums: usize,
downstream_compile_fixture: &'static str,
representative_port: &'static str,
status: &'static str,
}
/// Writes the reviewed first-release API/SemVer baseline and audit report.
///
/// # Errors
///
/// Returns an error when the mapped/exported surface or a quality invariant is
/// incomplete, or when the deterministic output cannot be written.
pub fn write_api_baseline(root: &Path) -> Result<()> {
let baseline = inspect(root)?;
fs::write(root.join(BASELINE_PATH), baseline_json(&baseline)?)?;
fs::write(root.join(REPORT_PATH), render_report(&baseline))?;
Ok(())
}
/// Audits the current API against the checked first-release `SemVer` baseline.
///
/// # Errors
///
/// Returns an error for missing/unexplained mappings, a changed public surface,
/// weak async/thread/error contracts, feature drift, leaked dependency types,
/// stale reports, or evidence I/O failures.
pub fn audit_api_surface(root: &Path, evidence: &Path) -> Result<()> {
if evidence.exists() {
return Err(MatrixError::new(format!(
"{} already exists; preserve or remove it before rerunning the audit",
evidence.display()
)));
}
let current = inspect(root)?;
let baseline_bytes = fs::read(root.join(BASELINE_PATH))?;
let baseline: ApiBaseline = serde_json::from_slice(&baseline_bytes)?;
if baseline != current {
return Err(MatrixError::new(
"public API differs from api/SEMVER-BASELINE.json; review the SemVer impact before recording a new baseline",
));
}
if fs::read_to_string(root.join(REPORT_PATH))? != render_report(&current) {
return Err(MatrixError::new(
"API/SemVer audit report is stale; run api-baseline-write after review",
));
}
let record = ApiEvidence {
schema: 1,
release: &current.release,
upstream_commit: &current.upstream_commit,
baseline_sha256: sha256(&baseline_bytes),
mapped_public_types: current.mapped_public_types,
mapped_members: current.mapped_members,
intentional_differences: current.intentional_differences,
unexplained_mappings: current.unexplained_mappings,
accidental_public_dependency_types: current.accidental_public_dependency_types,
thread_safe_dyn_traits: current.thread_safe_dyn_traits,
non_exhaustive_error_enums: current.non_exhaustive_error_enums,
downstream_compile_fixture: "tests/api-compile",
representative_port: "tests/semver-port",
status: "ok",
};
if let Some(parent) = evidence.parent() {
fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(evidence)?;
serde_json::to_writer_pretty(&mut file, &record)?;
file.write_all(b"\n")?;
file.sync_all()?;
Ok(())
}
fn inspect(root: &Path) -> Result<ApiBaseline> {
let (type_header, type_rows) = read_tsv(&root.join(TYPE_PATH))?;
let (member_header, member_rows) = read_tsv(&root.join(MEMBER_PATH))?;
validate_mapping_headers(&type_header, &member_header)?;
validate_unique(&type_rows, "csharp_type_id", "type mapping")?;
validate_unique(&member_rows, "csharp_id", "member mapping")?;
validate_unique(&member_rows, "rust_item_path", "Rust member destination")?;
validate_members(&member_rows)?;
validate_types(&type_rows)?;
let (public_type_ids, member_ids) = mapped_ids(&type_rows, &member_rows);
let (exported_types, exported_members, exported_bytes) = exported_mappings(root)?;
if public_type_ids != exported_types || member_ids != exported_members {
return Err(MatrixError::new(format!(
"mapped/exported coverage mismatch: types {}/{}, members {}/{}",
exported_types.len(),
public_type_ids.len(),
exported_members.len(),
member_ids.len()
)));
}
validate_non_exhaustive_errors(root)?;
validate_thread_safe_traits(root)?;
validate_dependency_roots(&member_rows)?;
let workspace = fs::read_to_string(root.join("Cargo.toml"))?;
let release = package_value(&workspace, "version")
.ok_or_else(|| MatrixError::new("workspace package version is missing"))?;
let (crates, declarations) = inspect_crates(root, &release, &type_rows, &member_rows)?;
let counts = quality_counts(&type_rows, &member_rows);
let baseline = ApiBaseline {
schema: 1,
release,
upstream_commit: UPSTREAM_COMMIT.to_owned(),
mapped_public_types: public_type_ids.len(),
mapped_members: member_ids.len(),
external_signature_types: type_rows
.iter()
.filter(|row| {
!field(row, "csharp_type_id").starts_with("T:LibreMetaverse")
&& field(row, "source_kind") != "referenced_support_trait"
})
.count(),
support_traits: type_rows
.iter()
.filter(|row| field(row, "source_kind") == "referenced_support_trait")
.count(),
intentional_differences: counts.intentional_differences,
descriptive_overloads: counts.descriptive_overloads,
async_functions: counts.async_functions,
send_boxed_futures: counts.send_boxed_futures,
thread_safe_callbacks: counts.thread_safe_callbacks,
thread_safe_dyn_traits: REQUIRED_THREAD_SAFE_TRAITS.len(),
non_exhaustive_error_enums: REQUIRED_NON_EXHAUSTIVE_ERRORS.len(),
enum_types: counts.enum_types,
bitflag_types: counts.bitflag_types,
unexplained_mappings: 0,
accidental_public_dependency_types: 0,
type_mapping_sha256: sha256(&fs::read(root.join(TYPE_PATH))?),
member_mapping_sha256: sha256(&fs::read(root.join(MEMBER_PATH))?),
exported_mapping_sha256: sha256(&exported_bytes),
rust_declaration_sha256: sha256(declarations.join("\n").as_bytes()),
crates,
};
validate_totals(&baseline)?;
Ok(baseline)
}
fn validate_mapping_headers(type_header: &[String], member_header: &[String]) -> Result<()> {
require_columns(
type_header,
&[
"csharp_type_id",
"csharp_signature",
"source_kind",
"rust_crate",
"rust_path",
"mapping_decision",
"inheritance_mapping",
"enum_mapping",
"status",
],
TYPE_PATH,
)?;
require_columns(
member_header,
&[
"csharp_id",
"csharp_kind",
"csharp_signature",
"rust_crate",
"rust_item_path",
"rust_signature",
"ownership",
"asyncness",
"error_model",
"overload_decision",
"mapping_kind",
"status",
],
MEMBER_PATH,
)
}
fn mapped_ids(type_rows: &[Row], member_rows: &[Row]) -> (BTreeSet<String>, BTreeSet<String>) {
let public_type_ids = type_rows
.iter()
.filter(|row| {
field(row, "csharp_type_id").starts_with("T:LibreMetaverse")
&& field(row, "source_kind") != "referenced_support_trait"
})
.map(|row| field(row, "csharp_type_id").to_owned())
.collect::<BTreeSet<_>>();
let member_ids = member_rows
.iter()
.map(|row| field(row, "csharp_id").to_owned())
.collect::<BTreeSet<_>>();
(public_type_ids, member_ids)
}
fn validate_dependency_roots(member_rows: &[Row]) -> Result<()> {
let accidental_roots = accidental_dependency_roots(member_rows);
if !accidental_roots.is_empty() {
return Err(MatrixError::new(format!(
"mapped public signatures expose dependency roots: {}",
accidental_roots.into_iter().collect::<Vec<_>>().join(", ")
)));
}
Ok(())
}
fn inspect_crates(
root: &Path,
release: &str,
type_rows: &[Row],
member_rows: &[Row],
) -> Result<(Vec<CrateSurface>, Vec<String>)> {
let mut declarations = Vec::new();
let mut crates = Vec::new();
for (name, relative) in PUBLIC_CRATES {
let directory = root.join(relative);
let manifest = fs::read_to_string(directory.join("Cargo.toml"))?;
if package_value(&manifest, "name").as_deref() != Some(name) {
return Err(MatrixError::new(format!(
"public crate manifest mismatch for {name}"
)));
}
let crate_version = if manifest
.lines()
.any(|line| line.trim() == "version.workspace = true")
{
release.to_owned()
} else {
package_value(&manifest, "version")
.ok_or_else(|| MatrixError::new(format!("public crate {name} lacks a version")))?
};
if crate_version != release {
return Err(MatrixError::new(format!(
"public crate {name} has version {crate_version}, expected release {release}"
)));
}
let features = parse_features(&manifest)?;
validate_features(name, &features)?;
let crate_declarations = crate_declarations(&directory)?;
validate_declaration_dependencies(name, &manifest, &crate_declarations)?;
let declaration_sha256 = sha256(crate_declarations.join("\n").as_bytes());
declarations.extend(
crate_declarations
.iter()
.map(|declaration| format!("{name}\t{declaration}")),
);
crates.push(CrateSurface {
name: name.to_owned(),
version: release.to_owned(),
features,
mapped_types: type_rows
.iter()
.filter(|row| {
field(row, "rust_crate") == name
&& field(row, "csharp_type_id").starts_with("T:LibreMetaverse")
&& field(row, "source_kind") != "referenced_support_trait"
})
.count(),
mapped_members: member_rows
.iter()
.filter(|row| field(row, "rust_crate") == name)
.count(),
public_declarations: crate_declarations.len(),
declaration_sha256,
});
}
declarations.sort();
Ok((crates, declarations))
}
fn quality_counts(type_rows: &[Row], member_rows: &[Row]) -> QualityCounts {
let intentional_differences = member_rows
.iter()
.filter(|row| field(row, "status") == "intentional_difference")
.count();
let descriptive_overloads = member_rows
.iter()
.filter(|row| field(row, "overload_decision") != "direct_snake_case")
.count();
let async_functions = member_rows
.iter()
.filter(|row| field(row, "asyncness") == "async")
.count();
let send_boxed_futures = member_rows
.iter()
.filter(|row| field(row, "asyncness") == "boxed_future")
.count();
let thread_safe_callbacks = member_rows
.iter()
.filter(|row| field(row, "rust_signature").contains("dyn Fn"))
.count();
let enum_types = type_rows
.iter()
.filter(|row| {
field(row, "csharp_type_id").starts_with("T:LibreMetaverse")
&& field(row, "source_kind") == "enum"
})
.count();
let bitflag_types = type_rows
.iter()
.filter(|row| {
field(row, "csharp_type_id").starts_with("T:LibreMetaverse")
&& field(row, "enum_mapping").starts_with("bitflags<")
})
.count();
QualityCounts {
intentional_differences,
descriptive_overloads,
async_functions,
send_boxed_futures,
thread_safe_callbacks,
enum_types,
bitflag_types,
}
}
fn validate_members(rows: &[Row]) -> Result<()> {
let ownership_terms = OWNERSHIP_TERMS.into_iter().collect::<BTreeSet<_>>();
for row in rows {
let id = field(row, "csharp_id");
if [
"csharp_id",
"csharp_kind",
"csharp_signature",
"rust_crate",
"rust_item_path",
"rust_signature",
"ownership",
"asyncness",
"error_model",
"overload_decision",
"mapping_kind",
"status",
]
.into_iter()
.any(|name| field(row, name).is_empty())
{
return Err(MatrixError::new(format!("mapping {id} has an empty field")));
}
if field(row, "ownership")
.split(',')
.any(|term| !ownership_terms.contains(term))
{
return Err(MatrixError::new(format!(
"mapping {id} has an unexplained ownership term"
)));
}
let signature = field(row, "rust_signature");
validate_rust_naming(id, signature)?;
match field(row, "asyncness") {
"sync" if signature.contains("pub async fn") => {
return Err(MatrixError::new(format!(
"mapping {id} marks an async function as synchronous"
)));
}
"async" if !signature.contains("pub async fn") => {
return Err(MatrixError::new(format!(
"mapping {id} lacks an async Rust signature"
)));
}
"boxed_future"
if !signature.contains("dyn std::future::Future")
|| !signature.contains("+ Send") =>
{
return Err(MatrixError::new(format!(
"mapping {id} lacks a Send boxed future"
)));
}
"sync" | "async" | "boxed_future" => {}
value => {
return Err(MatrixError::new(format!(
"mapping {id} has unknown asyncness {value}"
)));
}
}
if signature.contains("dyn Fn") && !signature.contains("+ Send + Sync") {
return Err(MatrixError::new(format!(
"mapping {id} exposes a callback without Send + Sync"
)));
}
match field(row, "error_model") {
"Result<_, crate::Error>" if !signature.contains("Result<") => {
return Err(MatrixError::new(format!(
"mapping {id} declares Result errors without a Result signature"
)));
}
"none" | "infallible_placeholder" if signature.contains("Result<") => {
return Err(MatrixError::new(format!(
"mapping {id} returns Result without a matching error model"
)));
}
"Result<_, crate::Error>" | "none" | "infallible_placeholder" => {}
value => {
return Err(MatrixError::new(format!(
"mapping {id} has unknown error model {value}"
)));
}
}
let intentional = field(row, "status") == "intentional_difference";
if intentional != (field(row, "mapping_kind") == "native_callback_replaces_delegate_apm") {
return Err(MatrixError::new(format!(
"mapping {id} has an unexplained intentional difference"
)));
}
if !matches!(field(row, "status"), "mapped" | "intentional_difference") {
return Err(MatrixError::new(format!(
"mapping {id} has an unresolved status"
)));
}
}
Ok(())
}
fn validate_rust_naming(id: &str, signature: &str) -> Result<()> {
let function = ["pub async fn ", "pub fn ", "async fn ", "fn "]
.into_iter()
.find_map(|prefix| signature.strip_prefix(prefix));
if let Some(function) = function {
let name = function
.split(|character: char| !(character.is_ascii_alphanumeric() || character == '_'))
.next()
.unwrap_or_default();
if name.is_empty()
|| name.starts_with(|character: char| character.is_ascii_digit())
|| !name.chars().all(|character| {
character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
})
{
return Err(MatrixError::new(format!(
"mapping {id} has a non-idiomatic Rust function name: {name}"
)));
}
}
for prefix in ["pub const ", "pub static "] {
if let Some(item) = signature.strip_prefix(prefix) {
let name = item
.split(|character: char| !(character.is_ascii_alphanumeric() || character == '_'))
.next()
.unwrap_or_default();
if name.is_empty()
|| !name.chars().all(|character| {
character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_'
})
{
return Err(MatrixError::new(format!(
"mapping {id} has a non-idiomatic Rust constant name: {name}"
)));
}
}
}
Ok(())
}
fn validate_types(rows: &[Row]) -> Result<()> {
for row in rows {
let id = field(row, "csharp_type_id");
if [
"csharp_type_id",
"csharp_signature",
"source_kind",
"rust_crate",
"rust_path",
"mapping_decision",
"inheritance_mapping",
"enum_mapping",
"status",
]
.into_iter()
.any(|name| field(row, name).is_empty())
|| field(row, "status") != "mapped"
{
return Err(MatrixError::new(format!(
"type mapping {id} is incomplete or unresolved"
)));
}
if !matches!(
field(row, "mapping_decision"),
"native_metacrate_type"
| "native_cross_platform_replacement"
| "stdlib_or_adopted_crate"
| "language"
| "native_metacrate_support_trait"
) {
return Err(MatrixError::new(format!(
"type mapping {id} has an unexplained decision"
)));
}
}
Ok(())
}
fn validate_totals(baseline: &ApiBaseline) -> Result<()> {
if baseline.mapped_public_types != 3_066
|| baseline.mapped_members != 30_789
|| baseline.external_signature_types != 142
|| baseline.support_traits != 2
|| baseline.intentional_differences != 80
|| baseline.descriptive_overloads != 7_151
|| baseline.async_functions != 334
|| baseline.send_boxed_futures != 26
|| baseline.thread_safe_callbacks != 137
|| baseline.enum_types != 243
|| baseline.bitflag_types != 46
|| baseline.unexplained_mappings != 0
|| baseline.accidental_public_dependency_types != 0
{
return Err(MatrixError::new(
"API quality totals differ from the reviewed first-release surface",
));
}
Ok(())
}
fn exported_mappings(root: &Path) -> Result<(BTreeSet<String>, BTreeSet<String>, Vec<u8>)> {
let mut types = BTreeSet::new();
let mut members = BTreeSet::new();
let mut canonical = Vec::new();
for (_, relative) in PUBLIC_CRATES {
let path = root.join(relative).join("src/generated.rs");
if !path.exists() {
continue;
}
for line in fs::read_to_string(&path)?.lines().map(str::trim) {
if let Some(id) = marker(line, "/// C# type: `") {
if !types.insert(id.to_owned()) {
return Err(MatrixError::new(format!(
"duplicate exported type marker {id}"
)));
}
canonical.push(format!("type\t{id}"));
}
if let Some(id) = marker(line, "/// C# member: `") {
if !members.insert(id.to_owned()) {
return Err(MatrixError::new(format!(
"duplicate exported member marker {id}"
)));
}
canonical.push(format!("member\t{id}"));
}
}
}
canonical.sort_unstable();
Ok((types, members, canonical.join("\n").into_bytes()))
}
fn marker<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
line.strip_prefix(prefix)?.strip_suffix("`.")
}
fn accidental_dependency_roots(rows: &[Row]) -> BTreeSet<String> {
let mut roots = BTreeSet::new();
for row in rows {
for root in path_roots(field(row, "rust_signature")) {
if !matches!(root.as_str(), "crate" | "std" | "core" | "self" | "super")
&& !root.starts_with("libremetaverse")
{
roots.insert(root);
}
}
}
roots
}
fn validate_declaration_dependencies(
crate_name: &str,
manifest: &str,
declarations: &[String],
) -> Result<()> {
let dependencies = dependency_roots(manifest);
for declaration in declarations {
let surface = dependency_surface(declaration);
for root in path_roots(&surface).intersection(&dependencies) {
if root.starts_with("libremetaverse") {
continue;
}
return Err(MatrixError::new(format!(
"public Rust declaration exposes dependency root {root}: {crate_name}/{declaration}"
)));
}
}
Ok(())
}
fn dependency_surface(declaration: &str) -> String {
let source = declaration
.split_once('\t')
.map_or(declaration, |(_, source)| source);
if !source.starts_with("pub struct ") && !source.starts_with("pub union ") {
return source.to_owned();
}
let (opening, closing) = if let Some(opening) = source.find('{') {
(opening, '}')
} else if let Some(opening) = source.find('(') {
(opening, ')')
} else {
return source.to_owned();
};
let Some(closing) = source.rfind(closing).filter(|closing| *closing > opening) else {
return source.to_owned();
};
let mut public = source[..opening].to_owned();
for field in split_top_level(&source[opening + 1..closing], ',') {
let field = field.trim();
if field.starts_with("pub ") {
public.push(' ');
public.push_str(field);
}
}
public
}
fn split_top_level(value: &str, separator: char) -> Vec<&str> {
let mut fields = Vec::new();
let mut start = 0;
let mut depth = 0usize;
for (index, character) in value.char_indices() {
match character {
'<' | '(' | '[' | '{' => depth += 1,
'>' | ')' | ']' | '}' => depth = depth.saturating_sub(1),
candidate if candidate == separator && depth == 0 => {
fields.push(&value[start..index]);
start = index + character.len_utf8();
}
_ => {}
}
}
fields.push(&value[start..]);
fields
}
fn dependency_roots(manifest: &str) -> BTreeSet<String> {
let mut roots = BTreeSet::new();
let mut normal_dependencies = false;
for line in manifest.lines().map(str::trim) {
if line.starts_with('[') {
normal_dependencies = line == "[dependencies]"
|| (line.starts_with("[target.") && line.ends_with(".dependencies]"));
continue;
}
if !normal_dependencies || line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((name, _)) = line.split_once('=') {
roots.insert(name.trim().replace('-', "_"));
}
}
roots
}
fn path_roots(value: &str) -> BTreeSet<String> {
let bytes = value.as_bytes();
let mut roots = BTreeSet::new();
let mut index = 0;
while index < bytes.len() {
if bytes[index].is_ascii_lowercase()
&& (index == 0
|| (!bytes[index - 1].is_ascii_alphanumeric()
&& bytes[index - 1] != b'_'
&& bytes[index - 1] != b':'))
{
let start = index;
index += 1;
while index < bytes.len()
&& (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_')
{
index += 1;
}
let mut separator = index;
while separator < bytes.len() && bytes[separator].is_ascii_whitespace() {
separator += 1;
}
if bytes.get(separator..separator + 2) == Some(b"::") {
roots.insert(value[start..index].to_owned());
}
} else {
index += 1;
}
}
roots
}
fn validate_non_exhaustive_errors(root: &Path) -> Result<()> {
for (relative, name) in REQUIRED_NON_EXHAUSTIVE_ERRORS {
let text = fs::read_to_string(root.join(relative))?;
let declaration = format!("pub enum {name}");
let Some(position) = text.find(&declaration) else {
return Err(MatrixError::new(format!(
"missing public error enum {name}"
)));
};
let prefix = &text[..position];
let attribute = prefix
.rsplit_once("#[non_exhaustive]")
.map(|(_, tail)| tail);
if attribute.is_none_or(|tail| tail.contains("pub enum")) {
return Err(MatrixError::new(format!(
"public error enum {name} must be non_exhaustive before release"
)));
}
}
Ok(())
}
fn validate_thread_safe_traits(root: &Path) -> Result<()> {
for (relative, declaration) in REQUIRED_THREAD_SAFE_TRAITS {
if !fs::read_to_string(root.join(relative))?.contains(declaration) {
return Err(MatrixError::new(format!(
"public callback trait lacks its Send + Sync contract: {declaration}"
)));
}
}
Ok(())
}
fn validate_features(name: &str, actual: &BTreeMap<String, Vec<String>>) -> Result<()> {
let expected = match name {
"libremetaverse" => BTreeMap::from([
("dds-bc67".to_owned(), vec!["dep:bcdec_rs".to_owned()]),
("default".to_owned(), vec!["dds-bc67".to_owned()]),
(
"jpeg2000".to_owned(),
vec!["libremetaverse-imaging/jpeg2000".to_owned()],
),
("vorbis".to_owned(), vec!["dep:vorbis_rs".to_owned()]),
]),
"libremetaverse-imaging" => BTreeMap::from([
("default".to_owned(), Vec::new()),
(
"jpeg2000".to_owned(),
vec!["dep:libremetaverse-openjpeg".to_owned()],
),
]),
"libremetaverse-imaging-skia" => BTreeMap::from([
("default".to_owned(), Vec::new()),
("skia".to_owned(), vec!["dep:skia-safe".to_owned()]),
]),
"libremetaverse-voice-webrtc" => BTreeMap::from([
("default".to_owned(), Vec::new()),
("real-audio".to_owned(), vec!["dep:cpal".to_owned()]),
]),
_ => BTreeMap::new(),
};
if actual != &expected {
return Err(MatrixError::new(format!(
"public feature surface changed for {name}: expected {expected:?}, found {actual:?}"
)));
}
Ok(())
}
fn parse_features(manifest: &str) -> Result<BTreeMap<String, Vec<String>>> {
let mut features = BTreeMap::new();
let mut in_features = false;
for line in manifest.lines() {
let line = line.trim();
if line.starts_with('[') {
in_features = line == "[features]";
continue;
}
if !in_features || line.is_empty() || line.starts_with('#') {
continue;
}
let (name, values) = line
.split_once('=')
.ok_or_else(|| MatrixError::new("feature declaration lacks ="))?;
let values = serde_json::from_str::<Vec<String>>(values.trim())?;
if features.insert(name.trim().to_owned(), values).is_some() {
return Err(MatrixError::new("duplicate feature declaration"));
}
}
Ok(features)
}
fn crate_declarations(directory: &Path) -> Result<Vec<String>> {
let mut files = Vec::new();
collect_rs(&directory.join("src"), &mut files)?;
files.sort();
let mut declarations = Vec::new();
for file in files {
let relative = file.strip_prefix(directory).unwrap_or(&file);
for declaration in extract_public_declarations(&fs::read_to_string(&file)?) {
declarations.push(format!("{}\t{declaration}", relative.display()));
}
}
declarations.sort();
Ok(declarations)
}
fn collect_rs(directory: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
for entry in fs::read_dir(directory)? {
let path = entry?.path();
if path.is_dir() {
collect_rs(&path, files)?;
} else if path.extension().and_then(|value| value.to_str()) == Some("rs") {
files.push(path);
}
}
Ok(())
}
fn extract_public_declarations(source: &str) -> Vec<String> {
let lines = source.lines().collect::<Vec<_>>();
let mut declarations = Vec::new();
let mut index = 0;
while index < lines.len() {
let trimmed = lines[index].trim_start();
if !trimmed.starts_with("pub ") {
index += 1;
continue;
}
let whole_block = ["pub struct ", "pub enum ", "pub union ", "pub trait "]
.iter()
.any(|prefix| trimmed.starts_with(prefix));
let inline_module = trimmed.starts_with("pub mod ") && trimmed.contains('{');
let mut chunk = Vec::new();
let mut braces = 0isize;
let mut opened = false;
loop {
let line = lines[index].trim();
if !line.starts_with("///") && !line.starts_with("//") {
let selected = if !whole_block && !inline_module {
line.split_once('{')
.map_or(line, |(header, _)| header.trim())
} else if inline_module {
line.split_once('{')
.map_or(line, |(header, _)| header.trim())
} else {
line
};
if !selected.is_empty() {
chunk.push(selected);
}
}
if whole_block {
braces += brace_delta(line);
opened |= line.contains('{');
}
let finished = if whole_block {
(opened && braces == 0) || (!opened && line.ends_with(';'))
} else {
line.contains('{') || line.ends_with(';')
};
index += 1;
if finished || index == lines.len() {
break;
}
}
let normalized = chunk
.join(" ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if !normalized.is_empty() {
declarations.push(normalized);
}
}
declarations
}
fn brace_delta(line: &str) -> isize {
let mut delta = 0;
let mut quoted = false;
let mut escaped = false;
let mut characters = line.chars().peekable();
while let Some(character) = characters.next() {
if !quoted && character == '/' && characters.peek() == Some(&'/') {
break;
}
if quoted {
if escaped {
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == '"' {
quoted = false;
}
continue;
}
match character {
'"' => quoted = true,
'{' => delta += 1,
'}' => delta -= 1,
_ => {}
}
}
delta
}
fn read_tsv(path: &Path) -> Result<(Vec<String>, Vec<Row>)> {
let text = fs::read_to_string(path)?;
let mut lines = text.lines();
let header = lines
.next()
.ok_or_else(|| MatrixError::new(format!("{} is empty", path.display())))?
.split('\t')
.map(str::to_owned)
.collect::<Vec<_>>();
let mut rows = Vec::new();
for line in lines.filter(|line| !line.is_empty()) {
let values = line.split('\t').collect::<Vec<_>>();
if values.len() != header.len() {
return Err(MatrixError::new(format!(
"{} contains a malformed row",
path.display()
)));
}
rows.push(
header
.iter()
.cloned()
.zip(values.into_iter().map(str::to_owned))
.collect(),
);
}
Ok((header, rows))
}
fn require_columns(header: &[String], required: &[&str], path: &str) -> Result<()> {
for field in required {
if !header.iter().any(|candidate| candidate == field) {
return Err(MatrixError::new(format!(
"{path} lacks required column {field}"
)));
}
}
Ok(())
}
fn validate_unique(rows: &[Row], key: &str, label: &str) -> Result<()> {
let mut values = BTreeSet::new();
for row in rows {
let value = field(row, key);
if value.is_empty() || !values.insert(value) {
return Err(MatrixError::new(format!(
"{label} values must be nonempty and unique: {value}"
)));
}
}
Ok(())
}
fn field<'a>(row: &'a Row, name: &str) -> &'a str {
row.get(name).map_or("", String::as_str)
}
fn package_value(manifest: &str, key: &str) -> Option<String> {
let mut in_package = false;
for line in manifest.lines() {
let line = line.trim();
if line.starts_with('[') {
in_package = matches!(line, "[package]" | "[workspace.package]");
continue;
}
if in_package
&& let Some(value) = line.strip_prefix(&format!("{key} = \""))
&& let Some(value) = value.strip_suffix('"')
{
return Some(value.to_owned());
}
}
None
}
fn baseline_json(baseline: &ApiBaseline) -> Result<Vec<u8>> {
let mut bytes = serde_json::to_vec_pretty(baseline)?;
bytes.push(b'\n');
Ok(bytes)
}
fn sha256(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut output = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write as _;
write!(output, "{byte:02x}").expect("writing to a String cannot fail");
}
output
}
fn render_report(baseline: &ApiBaseline) -> String {
let crates = baseline
.crates
.iter()
.map(|surface| {
let features = if surface.features.is_empty() {
"none".to_owned()
} else {
surface
.features
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
};
format!(
"| `{}` | {} | {} | {} | {} | `{}` |",
surface.name,
surface.mapped_types,
surface.mapped_members,
surface.public_declarations,
features,
surface.declaration_sha256
)
})
.collect::<Vec<_>>()
.join("\n");
format!(
"# First-release API and SemVer audit\n\n\
Generated by `metacrate-ci-matrix api-baseline-write`; do not edit by hand.\n\n\
The compatibility source is pinned at [`{commit}`](https://github.com/cinderblocks/libremetaverse/tree/{commit}). \
The baseline version is `{release}`.\n\n\
## Coverage and quality decisions\n\n\
- Mapped/exported public types: **{types} / {types}**; unexplained mappings: **0**.\n\
- Mapped/exported public members: **{members} / {members}**; duplicate destinations: **0**.\n\
- Rust naming and ownership: mapped function/constant names follow Rust conventions and every member uses a reviewed ownership term.\n\
- External signature types: **{external}**; referenced support traits: **{support}**; accidental dependency types exposed by mapped signatures or public Rust declarations: **0**.\n\
- Intentional delegate APM replacements: **{differences}**; each uses a native callback and is compiled by the downstream fixture.\n\
- Descriptively named overloads: **{overloads}**; names remain fixed by the mapping ledger.\n\
- Native `async fn` mappings: **{async_functions}**; boxed object-safe trait futures with explicit `Send`: **{boxed}**.\n\
- Callback signatures requiring `Send + Sync`: **{callbacks}**; reviewed dyn-compatible thread-safe boundary traits: **{traits}** (`IGridClient`, `IBakingTextureProvider`, `ICurrentOutfitPolicy`, `IRlvActionCallbacks`, `IRlvQueryCallbacks`, and `IVoiceLogger`).\n\
- C# enum mappings: **{enums}** ({bitflags} unknown-bit-retaining flag types); stable public error enums marked `non_exhaustive`: **{errors}**.\n\n\
## Crate baselines\n\n\
| Crate | Mapped types | Mapped members | Rust declarations | Features | Declaration SHA-256 |\n\
| --- | ---: | ---: | ---: | --- | --- |\n\
{crates}\n\n\
The exact mapping, exported-marker, feature, and normalized Rust declaration hashes are stored in \
[`SEMVER-BASELINE.json`](SEMVER-BASELINE.json). A change must be reviewed and classified before the baseline is re-recorded.\n\n\
## Intentional Rust migration recipes\n\n\
- C# `PascalCase` methods/properties become Rust `snake_case`; overloads use the ledger's stable descriptive suffix, never argument-count dispatch.\n\
- C# reference ownership becomes owned values, shared or mutable borrows, `Arc`-backed managers, and explicit optional values as recorded per member.\n\
- `Task<T>` becomes `async fn -> Result<T, Error>`; object-safe async traits return `Pin<Box<dyn Future<Output = Result<...>> + Send + '_>>`.\n\
- .NET events become `subscribe_*` calls returning an owned `Subscription`; callbacks are `Send + Sync` and dispatch outside internal locks.\n\
- `IDisposable` becomes explicit idempotent shutdown plus `Drop`; cancellation remains a typed `Error::Cancelled`.\n\
- Closed C# enums retain fixed discriminants and are SemVer-breaking to extend; flags retain unknown bits, while public Rust error enums are `non_exhaustive`.\n\
- Optional native backends remain feature-gated (`jpeg2000`, `skia`, `vorbis`, `real-audio`); default and empty-default sets are part of the baseline.\n\
- Capability HTTP injection uses project-owned `HttpMessageHandler`, `CapsHttpLimits`, and `with_native_transport`; the internal `reqwest` client is not part of the public surface.\n\
- External C# signature types map to core/std, adopted cross-platform crates, or project-owned boundary types; no third-party crate path leaks through mapped signatures or public Rust declarations.\n\n\
## Compile consumers\n\n\
- `tests/api-compile` compiles every mapped type/member from a standalone downstream workspace.\n\
- `tests/semver-port` is a runnable representative port covering lifecycle, value types, structured data, imaging, RLV, cancellation, dyn trait, and `Send`/`Sync` contracts without network access.\n",
commit = baseline.upstream_commit,
release = baseline.release,
types = baseline.mapped_public_types,
members = baseline.mapped_members,
external = baseline.external_signature_types,
support = baseline.support_traits,
differences = baseline.intentional_differences,
overloads = baseline.descriptive_overloads,
async_functions = baseline.async_functions,
boxed = baseline.send_boxed_futures,
callbacks = baseline.thread_safe_callbacks,
traits = baseline.thread_safe_dyn_traits,
enums = baseline.enum_types,
bitflags = baseline.bitflag_types,
errors = baseline.non_exhaustive_error_enums,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn declaration_extractor_ignores_function_bodies_and_private_items() {
let source = r"
struct Private;
pub struct Public { pub value: i32, hidden: bool }
impl Public {
pub fn value(
&self,
) -> i32 { self.value }
fn hidden(&self) -> bool { self.hidden }
}
";
let declarations = extract_public_declarations(source);
assert_eq!(declarations.len(), 2);
assert!(declarations[0].starts_with("pub struct Public"));
assert_eq!(declarations[1], "pub fn value( &self, ) -> i32");
assert!(!declarations.join(" ").contains("self.value"));
}
#[test]
fn path_root_parser_ignores_nested_modules() {
let roots =
path_roots("pub fn f(value: libremetaverse::packets::Packet) -> std::io::Result<()>;");
assert_eq!(
roots,
BTreeSet::from(["libremetaverse".to_owned(), "std".to_owned()])
);
}
#[test]
fn dependency_root_parser_covers_normal_target_and_renamed_crates() {
let manifest = r#"
[dependencies]
normal-crate = "1"
[dev-dependencies]
ignored_dev = "1"
[target.'cfg(unix)'.dependencies]
target-crate = "1"
[build-dependencies]
ignored_build = "1"
"#;
assert_eq!(
dependency_roots(manifest),
BTreeSet::from(["normal_crate".to_owned(), "target_crate".to_owned()])
);
}
#[test]
fn dependency_surface_excludes_private_but_retains_public_struct_fields() {
let declaration = "src/example.rs\tpub struct Example<T> { private: dep::Hidden<T>, pub visible: dep::Visible<(T, T)>, pub(crate) scoped: dep::Scoped, }";
assert_eq!(
dependency_surface(declaration),
"pub struct Example<T> pub visible: dep::Visible<(T, T)>"
);
assert_eq!(
dependency_surface("src/example.rs\tpub struct Tuple(pub dep::Visible, dep::Hidden);"),
"pub struct Tuple pub dep::Visible"
);
}
#[test]
fn naming_audit_accepts_rust_conventions_and_rejects_pascal_case_functions() {
validate_rust_naming(
"good",
"pub async fn request_asset_2() -> Result<(), Error>",
)
.unwrap();
validate_rust_naming("constant", "pub const MAX_BYTES: usize").unwrap();
assert!(validate_rust_naming("bad", "pub fn RequestAsset() -> bool").is_err());
}
#[test]
fn repository_surface_has_reviewed_totals() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let baseline = inspect(&root).unwrap();
assert_eq!(baseline.mapped_public_types, 3_066);
assert_eq!(baseline.mapped_members, 30_789);
assert_eq!(baseline.unexplained_mappings, 0);
assert_eq!(baseline.accidental_public_dependency_types, 0);
}
}