From 7f68b2220bd2b456793cbdc9d11d9aa2126e9d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CWilfred007=E2=80=9D?= <β€œadzerwilfred007@gmail.com”> Date: Sun, 28 Jun 2026 01:09:19 +0100 Subject: [PATCH] tooling sanctifier core implementation --- DOCUMENTATION_INDEX.md | 15 + tests/fixtures/contract_with_storage_types.rs | 55 ++ tests/fixtures/minimal_contract.rs | 14 + tests/fixtures/multi_contract.rs | 68 +++ .../sanctifier-core/src/contract_discovery.rs | 531 ++++++++++++++++++ tooling/sanctifier-core/src/lib.rs | 2 + tooling/sanctifier-core/src/parser.rs | 183 ++++++ .../tests/parser_contract_discovery_test.rs | 257 +++++++++ 8 files changed, 1125 insertions(+) create mode 100644 tests/fixtures/contract_with_storage_types.rs create mode 100644 tests/fixtures/minimal_contract.rs create mode 100644 tests/fixtures/multi_contract.rs create mode 100644 tooling/sanctifier-core/src/contract_discovery.rs create mode 100644 tooling/sanctifier-core/src/parser.rs create mode 100644 tooling/sanctifier-core/tests/parser_contract_discovery_test.rs diff --git a/DOCUMENTATION_INDEX.md b/DOCUMENTATION_INDEX.md index 47a53ac4..645b40f0 100644 --- a/DOCUMENTATION_INDEX.md +++ b/DOCUMENTATION_INDEX.md @@ -85,6 +85,21 @@ ## πŸ› οΈ Component Documentation +### sanctifier-core: Parser + Contract Discovery + +**[tooling/sanctifier-core/src/parser.rs](tooling/sanctifier-core/src/parser.rs)** β€” shared source parsing with input validation +- `parse_source(source: &str) -> Result` β€” single canonical entry point for all rules +- Runs all guards from `input_validation` (size, null bytes) before any AST work +- Returns `ParseError::Validation` or `ParseError::Syntax` on failure + +**[tooling/sanctifier-core/src/contract_discovery.rs](tooling/sanctifier-core/src/contract_discovery.rs)** β€” Soroban contract discovery +- `discover_contracts(file: &syn::File) -> Vec` β€” maps `#[contract]` structs to their `#[contractimpl]` blocks +- Collects public functions per contract and `#[contracttype]` storage types +- `DiscoveredFunction.is_reserved` flags `__constructor` / `__check_auth` entry-points +- `DiscoveredContract::public_functions()` iterator excludes reserved entry-points +- Unit tests in each module; integration tests in `tests/parser_contract_discovery_test.rs` +- Fixtures: `tests/fixtures/minimal_contract.rs`, `multi_contract.rs`, `contract_with_storage_types.rs` + ### Contract ABI / Interface Reference **[docs/contract-interfaces.md](docs/contract-interfaces.md)** - Public ABI for all contracts in `contracts/*` - Function signatures and descriptions for every contract diff --git a/tests/fixtures/contract_with_storage_types.rs b/tests/fixtures/contract_with_storage_types.rs new file mode 100644 index 00000000..00731a5c --- /dev/null +++ b/tests/fixtures/contract_with_storage_types.rs @@ -0,0 +1,55 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + +// ── Storage layout ───────────────────────────────────────────────────────────── + +#[contracttype] +pub enum DataKey { + Admin, + Counter, + User(Address), +} + +#[contracttype] +pub struct Config { + pub max_value: u64, + pub owner: Address, +} + +// ── Contract ─────────────────────────────────────────────────────────────────── + +#[contract] +pub struct Registry; + +#[contractimpl] +impl Registry { + /// Reserved constructor β€” sets up initial state. + pub fn __constructor(env: Env, admin: Address, max_value: u64) { + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Counter, &0u64); + env.storage().instance().set( + &DataKey::Admin, + &Config { + max_value, + owner: admin, + }, + ); + } + + pub fn increment(env: Env, caller: Address) -> u64 { + caller.require_auth(); + let count: u64 = env.storage().instance().get(&DataKey::Counter).unwrap_or(0); + let new_count = count.checked_add(1).expect("counter overflow"); + env.storage().instance().set(&DataKey::Counter, &new_count); + new_count + } + + pub fn get_count(env: Env) -> u64 { + env.storage().instance().get(&DataKey::Counter).unwrap_or(0) + } + + pub fn admin(env: Env) -> Address { + env.storage().instance().get(&DataKey::Admin).unwrap() + } +} diff --git a/tests/fixtures/minimal_contract.rs b/tests/fixtures/minimal_contract.rs new file mode 100644 index 00000000..88d7d289 --- /dev/null +++ b/tests/fixtures/minimal_contract.rs @@ -0,0 +1,14 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, Env}; + +/// The smallest valid Soroban contract β€” one public function, no storage, no auth. +#[contract] +pub struct MinimalContract; + +#[contractimpl] +impl MinimalContract { + pub fn ping(_env: Env) -> u32 { + 42 + } +} diff --git a/tests/fixtures/multi_contract.rs b/tests/fixtures/multi_contract.rs new file mode 100644 index 00000000..de58b2a8 --- /dev/null +++ b/tests/fixtures/multi_contract.rs @@ -0,0 +1,68 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + +// ── Shared storage types ─────────────────────────────────────────────────────── + +#[contracttype] +pub enum TokenKey { + Balance(Address), + Admin, +} + +#[contracttype] +pub enum VaultKey { + Depositor(Address), + TotalDeposits, +} + +// ── Contract A: simple token ────────────────────────────────────────────────── + +#[contract] +pub struct TokenA; + +#[contractimpl] +impl TokenA { + pub fn initialize(env: Env, admin: Address) { + admin.require_auth(); + env.storage().instance().set(&TokenKey::Admin, &admin); + } + + pub fn balance(env: Env, user: Address) -> i128 { + env.storage().persistent().get(&TokenKey::Balance(user)).unwrap_or(0) + } + + pub fn transfer(env: Env, from: Address, to: Address, amount: i128) { + from.require_auth(); + let from_bal: i128 = + env.storage().persistent().get(&TokenKey::Balance(from.clone())).unwrap_or(0); + let to_bal: i128 = + env.storage().persistent().get(&TokenKey::Balance(to.clone())).unwrap_or(0); + env.storage().persistent().set(&TokenKey::Balance(from), &(from_bal - amount)); + env.storage().persistent().set(&TokenKey::Balance(to), &(to_bal + amount)); + } +} + +// ── Contract B: vault ───────────────────────────────────────────────────────── + +#[contract] +pub struct VaultB; + +#[contractimpl] +impl VaultB { + pub fn deposit(env: Env, user: Address, amount: i128) { + user.require_auth(); + let current: i128 = env + .storage() + .persistent() + .get(&VaultKey::Depositor(user.clone())) + .unwrap_or(0); + env.storage() + .persistent() + .set(&VaultKey::Depositor(user), &(current + amount)); + } + + pub fn total(env: Env) -> i128 { + env.storage().persistent().get(&VaultKey::TotalDeposits).unwrap_or(0) + } +} diff --git a/tooling/sanctifier-core/src/contract_discovery.rs b/tooling/sanctifier-core/src/contract_discovery.rs new file mode 100644 index 00000000..6d9334ba --- /dev/null +++ b/tooling/sanctifier-core/src/contract_discovery.rs @@ -0,0 +1,531 @@ +//! Contract discovery β€” identifies `#[contract]`, `#[contractimpl]`, and +//! `#[contracttype]` items in a parsed Soroban source file. +//! +//! The discovery layer answers three questions per source file: +//! 1. Which structs are Soroban contract entry points (`#[contract]`)? +//! 2. Which impl blocks expose public functions (`#[contractimpl]`)? +//! 3. Which types define on-chain storage layout (`#[contracttype]`)? +//! +//! These answers feed every downstream analysis pass so they do not have to +//! re-implement the same attribute scanning and structβ†’impl mapping. +//! +//! # Usage +//! +//! ```rust,ignore +//! use sanctifier_core::{parser, contract_discovery}; +//! +//! let parsed = parser::parse_source(source)?; +//! let contracts = contract_discovery::discover_contracts(&parsed.file); +//! +//! for contract in &contracts { +//! println!("contract: {}", contract.struct_name); +//! for f in contract.public_functions() { +//! println!(" pub fn {} (line {})", f.name, f.line); +//! } +//! } +//! ``` +//! +//! # Mapping rules +//! +//! - A `#[contract]` struct with a matching `#[contractimpl]` block produces +//! one [`DiscoveredContract`] with both `has_contract_attr` and +//! `has_contractimpl` set to `true`. +//! - A `#[contractimpl]` block whose self-type has **no** `#[contract]` struct +//! in the same file produces an orphan entry with `has_contract_attr: false`. +//! - A `#[contract]` struct with **no** impl block produces an entry with +//! `has_contractimpl: false` and an empty `public_functions` list. +//! - `#[contracttype]` types are attached to every [`DiscoveredContract`] +//! in the file (storage types are file-scoped, not per-contract). + +use std::collections::BTreeMap; +use syn::{spanned::Spanned, File, ImplItem, Item, Meta, Type}; + +// ── Public data types ───────────────────────────────────────────────────────── + +/// A public function discovered inside a `#[contractimpl]` block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiscoveredFunction { + /// Function name (e.g. `"transfer"`). + pub name: String, + /// 1-based source line where the function is declared. + pub line: usize, + /// `true` if this function is a reserved Soroban entry-point + /// (`__constructor`, `__check_auth`). + pub is_reserved: bool, +} + +/// A `#[contracttype]` enum or struct found at the file level. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StorageType { + /// Name of the type (e.g. `"DataKey"`). + pub name: String, + /// `"struct"` or `"enum"`. + pub kind: &'static str, +} + +/// A Soroban contract discovered in a source file. +/// +/// See the [module-level documentation](self) for the mapping rules that +/// determine how structs and impl blocks are combined into this structure. +#[derive(Debug, Clone)] +pub struct DiscoveredContract { + /// Name of the contract struct (taken from the `#[contract]` struct or the + /// self-type of the `#[contractimpl]` block when no struct was found). + pub struct_name: String, + /// Whether a `#[contract]` attribute was found on a struct with this name. + pub has_contract_attr: bool, + /// Whether at least one `#[contractimpl]` block was found for this struct. + pub has_contractimpl: bool, + pub(crate) fns: Vec, + /// All `#[contracttype]` types found in the file. + pub storage_types: Vec, +} + +impl DiscoveredContract { + /// Returns only the non-reserved public functions. + pub fn public_functions(&self) -> impl Iterator { + self.fns.iter().filter(|f| !f.is_reserved) + } + + /// Returns all public functions, including reserved entry-points + /// (`__constructor`, `__check_auth`). + pub fn all_public_functions(&self) -> &[DiscoveredFunction] { + &self.fns + } +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +/// Soroban-defined reserved function names that are not user-callable. +const RESERVED_ENTRYPOINTS: &[&str] = &["__constructor", "__check_auth"]; + +fn has_attr_named(attrs: &[syn::Attribute], name: &str) -> bool { + attrs.iter().any(|attr| { + if let Meta::Path(path) = &attr.meta { + path.is_ident(name) || path.segments.iter().any(|s| s.ident == name) + } else { + false + } + }) +} + +fn type_to_name(ty: &Type) -> Option { + if let Type::Path(tp) = ty { + tp.path.segments.last().map(|s| s.ident.to_string()) + } else { + None + } +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/// Discover all contracts and storage types in a parsed Soroban source file. +/// +/// Returns one [`DiscoveredContract`] per unique struct name encountered via +/// either a `#[contract]` annotation or a `#[contractimpl]` impl block. +/// Results are returned in deterministic (lexicographic) order by struct name. +/// +/// Returns an empty `Vec` when the file contains no Soroban-specific +/// attributes (i.e. plain Rust code or an empty AST). +pub fn discover_contracts(file: &File) -> Vec { + // ── Phase 1: collect file-scoped #[contracttype] types ──────────────────── + let storage_types: Vec = file + .items + .iter() + .filter_map(|item| match item { + Item::Struct(s) if has_attr_named(&s.attrs, "contracttype") => Some(StorageType { + name: s.ident.to_string(), + kind: "struct", + }), + Item::Enum(e) if has_attr_named(&e.attrs, "contracttype") => Some(StorageType { + name: e.ident.to_string(), + kind: "enum", + }), + _ => None, + }) + .collect(); + + // ── Phase 2: seed the map from #[contract] structs ──────────────────────── + let contract_struct_names: Vec = file + .items + .iter() + .filter_map(|item| { + if let Item::Struct(s) = item { + if has_attr_named(&s.attrs, "contract") { + return Some(s.ident.to_string()); + } + } + None + }) + .collect(); + + let mut by_name: BTreeMap = contract_struct_names + .iter() + .map(|name| { + ( + name.clone(), + DiscoveredContract { + struct_name: name.clone(), + has_contract_attr: true, + has_contractimpl: false, + fns: vec![], + storage_types: storage_types.clone(), + }, + ) + }) + .collect(); + + // ── Phase 3: attach #[contractimpl] functions ───────────────────────────── + for item in &file.items { + let Item::Impl(impl_block) = item else { + continue; + }; + if !has_attr_named(&impl_block.attrs, "contractimpl") { + continue; + } + + let struct_name = type_to_name(&impl_block.self_ty) + .unwrap_or_else(|| "".to_string()); + + let entry = by_name.entry(struct_name.clone()).or_insert_with(|| DiscoveredContract { + struct_name: struct_name.clone(), + has_contract_attr: contract_struct_names.contains(&struct_name), + has_contractimpl: false, + fns: vec![], + storage_types: storage_types.clone(), + }); + entry.has_contractimpl = true; + + for impl_item in &impl_block.items { + if let ImplItem::Fn(f) = impl_item { + if matches!(f.vis, syn::Visibility::Public(_)) { + let name = f.sig.ident.to_string(); + let line = f.sig.ident.span().start().line; + entry.fns.push(DiscoveredFunction { + is_reserved: RESERVED_ENTRYPOINTS.contains(&name.as_str()), + name, + line, + }); + } + } + } + } + + by_name.into_values().collect() +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::parse_source; + + fn file_from(src: &str) -> syn::File { + parse_source(src).expect("test source must parse").file + } + + // ── No Soroban attributes ───────────────────────────────────────────────── + + #[test] + fn empty_file_yields_no_contracts() { + let file = file_from(" "); + assert!(discover_contracts(&file).is_empty()); + } + + #[test] + fn plain_impl_without_contractimpl_yields_no_contracts() { + let file = file_from( + r#" + impl Foo { + pub fn bar(_env: Env) {} + } + "#, + ); + assert!(discover_contracts(&file).is_empty()); + } + + #[test] + fn only_contracttype_without_contract_yields_no_contracts() { + let file = file_from( + r#" + #[contracttype] + pub enum DataKey { A, B } + "#, + ); + assert!(discover_contracts(&file).is_empty()); + } + + // ── #[contract] struct only ─────────────────────────────────────────────── + + #[test] + fn contract_struct_without_impl_is_discovered() { + let file = file_from( + r#" + #[contract] + pub struct MyContract; + "#, + ); + let contracts = discover_contracts(&file); + assert_eq!(contracts.len(), 1); + assert_eq!(contracts[0].struct_name, "MyContract"); + assert!(contracts[0].has_contract_attr); + assert!(!contracts[0].has_contractimpl); + assert!(contracts[0].fns.is_empty()); + } + + // ── #[contractimpl] only (orphan) ───────────────────────────────────────── + + #[test] + fn contractimpl_without_contract_attr_is_discovered_as_orphan() { + let file = file_from( + r#" + #[contractimpl] + impl Orphan { + pub fn hello(_env: Env) -> u32 { 42 } + } + "#, + ); + let contracts = discover_contracts(&file); + assert_eq!(contracts.len(), 1); + let c = &contracts[0]; + assert_eq!(c.struct_name, "Orphan"); + assert!(!c.has_contract_attr, "no #[contract] struct in source"); + assert!(c.has_contractimpl); + assert_eq!(c.fns.len(), 1); + assert_eq!(c.fns[0].name, "hello"); + } + + // ── Full contract (struct + impl) ───────────────────────────────────────── + + #[test] + fn full_contract_collects_public_functions() { + let file = file_from( + r#" + #[contract] + pub struct Token; + #[contractimpl] + impl Token { + pub fn transfer(_env: Env, _from: Address, _to: Address, _amount: i128) {} + fn internal_helper() {} + pub fn balance(_env: Env, _id: Address) -> i128 { 0 } + } + "#, + ); + let contracts = discover_contracts(&file); + assert_eq!(contracts.len(), 1); + let c = &contracts[0]; + assert!(c.has_contract_attr); + assert!(c.has_contractimpl); + assert_eq!(c.fns.len(), 2, "only public functions are collected"); + let names: Vec<&str> = c.fns.iter().map(|f| f.name.as_str()).collect(); + assert!(names.contains(&"transfer")); + assert!(names.contains(&"balance")); + assert!(!names.contains(&"internal_helper")); + } + + #[test] + fn private_functions_are_excluded() { + let file = file_from( + r#" + #[contract] + pub struct Vault; + #[contractimpl] + impl Vault { + pub fn deposit(_env: Env) {} + fn _internal() {} + pub(crate) fn semi_private() {} + } + "#, + ); + let contracts = discover_contracts(&file); + assert_eq!(contracts[0].fns.len(), 1); + assert_eq!(contracts[0].fns[0].name, "deposit"); + } + + // ── Reserved entry-points ───────────────────────────────────────────────── + + #[test] + fn reserved_entrypoints_are_flagged_but_still_collected() { + let file = file_from( + r#" + #[contract] + pub struct MyContract; + #[contractimpl] + impl MyContract { + pub fn __constructor(_env: Env) {} + pub fn __check_auth(_env: Env) {} + pub fn work(_env: Env) {} + } + "#, + ); + let contracts = discover_contracts(&file); + let fns = contracts[0].all_public_functions(); + assert_eq!(fns.len(), 3); + + let constructor = fns.iter().find(|f| f.name == "__constructor").unwrap(); + assert!(constructor.is_reserved); + + let check_auth = fns.iter().find(|f| f.name == "__check_auth").unwrap(); + assert!(check_auth.is_reserved); + + let work = fns.iter().find(|f| f.name == "work").unwrap(); + assert!(!work.is_reserved); + } + + #[test] + fn public_functions_iterator_excludes_reserved() { + let file = file_from( + r#" + #[contract] + pub struct MyContract; + #[contractimpl] + impl MyContract { + pub fn __constructor(_env: Env) {} + pub fn do_work(_env: Env) {} + } + "#, + ); + let contracts = discover_contracts(&file); + let names: Vec<&str> = contracts[0].public_functions().map(|f| f.name.as_str()).collect(); + assert_eq!(names, vec!["do_work"]); + } + + // ── Storage types ───────────────────────────────────────────────────────── + + #[test] + fn contracttype_enum_is_collected_as_storage_type() { + let file = file_from( + r#" + #[contracttype] + pub enum DataKey { Admin, Balance } + #[contract] + pub struct Token; + "#, + ); + let contracts = discover_contracts(&file); + assert_eq!(contracts[0].storage_types.len(), 1); + assert_eq!(contracts[0].storage_types[0].name, "DataKey"); + assert_eq!(contracts[0].storage_types[0].kind, "enum"); + } + + #[test] + fn contracttype_struct_is_collected_as_storage_type() { + let file = file_from( + r#" + #[contracttype] + pub struct Config { pub limit: u32 } + #[contract] + pub struct Token; + "#, + ); + let contracts = discover_contracts(&file); + let st = &contracts[0].storage_types[0]; + assert_eq!(st.name, "Config"); + assert_eq!(st.kind, "struct"); + } + + #[test] + fn multiple_contracttype_items_are_all_collected() { + let file = file_from( + r#" + #[contracttype] + pub enum DataKey { Admin, Balance } + #[contracttype] + pub struct Config { pub limit: u32 } + #[contract] + pub struct Token; + "#, + ); + let contracts = discover_contracts(&file); + assert_eq!(contracts[0].storage_types.len(), 2); + let names: Vec<&str> = contracts[0].storage_types.iter().map(|t| t.name.as_str()).collect(); + assert!(names.contains(&"DataKey")); + assert!(names.contains(&"Config")); + } + + #[test] + fn storage_types_are_shared_across_all_contracts() { + let file = file_from( + r#" + #[contracttype] + pub enum SharedKey { X } + #[contract] + pub struct A; + #[contractimpl] + impl A { pub fn a(_env: Env) {} } + #[contract] + pub struct B; + #[contractimpl] + impl B { pub fn b(_env: Env) {} } + "#, + ); + let contracts = discover_contracts(&file); + assert_eq!(contracts.len(), 2); + for c in &contracts { + assert_eq!(c.storage_types.len(), 1, "contract {} missing storage type", c.struct_name); + } + } + + // ── Multiple contracts ──────────────────────────────────────────────────── + + #[test] + fn two_contracts_in_one_file_are_each_discovered() { + let file = file_from( + r#" + #[contract] + pub struct TokenA; + #[contractimpl] + impl TokenA { + pub fn name_a(_env: Env) -> u32 { 1 } + } + #[contract] + pub struct TokenB; + #[contractimpl] + impl TokenB { + pub fn name_b(_env: Env) -> u32 { 2 } + } + "#, + ); + let contracts = discover_contracts(&file); + assert_eq!(contracts.len(), 2); + let names: Vec<&str> = contracts.iter().map(|c| c.struct_name.as_str()).collect(); + assert!(names.contains(&"TokenA")); + assert!(names.contains(&"TokenB")); + } + + #[test] + fn results_are_in_deterministic_order() { + let file = file_from( + r#" + #[contract] pub struct Zebra; + #[contractimpl] impl Zebra { pub fn z(_env: Env) {} } + #[contract] pub struct Apple; + #[contractimpl] impl Apple { pub fn a(_env: Env) {} } + "#, + ); + let contracts = discover_contracts(&file); + // BTreeMap guarantees lexicographic order. + assert_eq!(contracts[0].struct_name, "Apple"); + assert_eq!(contracts[1].struct_name, "Zebra"); + } + + // ── Line numbers ────────────────────────────────────────────────────────── + + #[test] + fn function_line_numbers_are_positive() { + let file = file_from( + r#" + #[contract] + pub struct MyContract; + #[contractimpl] + impl MyContract { + pub fn entry(_env: Env) {} + } + "#, + ); + let contracts = discover_contracts(&file); + let f = &contracts[0].fns[0]; + assert!(f.line > 0, "line must be > 0, got {}", f.line); + } +} diff --git a/tooling/sanctifier-core/src/lib.rs b/tooling/sanctifier-core/src/lib.rs index 477cbd48..77c19c37 100644 --- a/tooling/sanctifier-core/src/lib.rs +++ b/tooling/sanctifier-core/src/lib.rs @@ -51,10 +51,12 @@ use thiserror::Error; pub mod analysis_cache; pub mod complexity; +pub mod contract_discovery; pub mod finding_codes; pub mod gas_estimator; pub mod gas_report; pub mod input_validation; +pub mod parser; pub mod patcher; pub mod reentrancy; pub mod rules; diff --git a/tooling/sanctifier-core/src/parser.rs b/tooling/sanctifier-core/src/parser.rs new file mode 100644 index 00000000..384dfe64 --- /dev/null +++ b/tooling/sanctifier-core/src/parser.rs @@ -0,0 +1,183 @@ +//! Shared source-code parsing with input validation. +//! +//! All analysis entry points should call [`parse_source`] instead of calling +//! `syn::parse_str` directly so that every guard from [`crate::input_validation`] +//! is applied consistently before AST work begins. +//! +//! # Example +//! +//! ```rust,ignore +//! use sanctifier_core::parser; +//! +//! match parser::parse_source(source) { +//! Ok(parsed) => { /* walk parsed.file */ } +//! Err(parser::ParseError::Validation(e)) => eprintln!("bad input: {}", e), +//! Err(parser::ParseError::Syntax(e)) => eprintln!("parse error: {}", e), +//! } +//! ``` + +use crate::input_validation::{self, ValidationError}; +use syn::File; + +/// A validated and successfully parsed Rust source file. +pub struct ParsedSource { + /// The AST produced by `syn`. + pub file: File, +} + +/// Errors returned by [`parse_source`]. +#[derive(Debug)] +pub enum ParseError { + /// Input rejected by a validation guard (empty, too large, null bytes, …). + Validation(ValidationError), + /// Source passed validation but is not syntactically valid Rust. + Syntax(syn::Error), +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Validation(e) => write!(f, "validation error: {}", e), + Self::Syntax(e) => write!(f, "syntax error: {}", e), + } + } +} + +impl std::error::Error for ParseError {} + +/// Validate and parse `source` into a [`ParsedSource`]. +/// +/// This is the canonical entry point for converting raw Rust source text into +/// an AST that rules and analysis passes can inspect. All input guards from +/// [`crate::input_validation`] run *before* any parsing work is attempted. +/// +/// # Errors +/// +/// - [`ParseError::Validation`] β€” a size or content guard failed +/// (e.g. empty source, null bytes, oversized input). +/// - [`ParseError::Syntax`] β€” `syn` could not parse the text as a Rust file. +pub fn parse_source(source: &str) -> Result { + input_validation::validate_source_all(source).map_err(ParseError::Validation)?; + let file = syn::parse_str::(source).map_err(ParseError::Syntax)?; + Ok(ParsedSource { file }) +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::input_validation::MAX_SOURCE_BYTES; + + // ── Validation guards ───────────────────────────────────────────────────── + + #[test] + fn empty_source_is_rejected_with_validation_error() { + let err = parse_source("").unwrap_err(); + assert!( + matches!(err, ParseError::Validation(_)), + "expected Validation, got: {err}" + ); + let msg = err.to_string(); + assert!( + msg.to_lowercase().contains("empty") || msg.contains("EMPTY"), + "message should mention empty source; got: {msg}" + ); + } + + #[test] + fn oversized_source_is_rejected_with_validation_error() { + let over = "x".repeat(MAX_SOURCE_BYTES + 1); + let err = parse_source(&over).unwrap_err(); + assert!( + matches!(err, ParseError::Validation(_)), + "expected Validation, got: {err}" + ); + } + + #[test] + fn null_byte_in_source_is_rejected_with_validation_error() { + let err = parse_source("fn foo() { let _ = \0; }").unwrap_err(); + assert!( + matches!(err, ParseError::Validation(_)), + "expected Validation, got: {err}" + ); + } + + // ── Syntax errors ───────────────────────────────────────────────────────── + + #[test] + fn invalid_syntax_returns_syntax_error() { + let err = parse_source("this is {{ not valid rust!!!").unwrap_err(); + assert!( + matches!(err, ParseError::Syntax(_)), + "expected Syntax, got: {err}" + ); + } + + #[test] + fn unclosed_brace_returns_syntax_error() { + let err = parse_source("fn foo() {").unwrap_err(); + assert!(matches!(err, ParseError::Syntax(_))); + } + + // ── Successful parses ───────────────────────────────────────────────────── + + #[test] + fn minimal_valid_source_parses_to_one_item() { + let src = "pub fn add(a: u32, b: u32) -> u32 { a + b }"; + let parsed = parse_source(src).unwrap(); + assert_eq!(parsed.file.items.len(), 1); + } + + #[test] + fn whitespace_only_source_parses_to_empty_ast() { + // Whitespace passes size and null-byte guards but produces no items. + let parsed = parse_source(" \n\t ").unwrap(); + assert!(parsed.file.items.is_empty()); + } + + #[test] + fn soroban_contract_skeleton_parses_successfully() { + let src = r#" + use soroban_sdk::{contract, contractimpl, Env}; + + #[contract] + pub struct MyContract; + + #[contractimpl] + impl MyContract { + pub fn hello(_env: Env) -> u32 { 42 } + } + "#; + let parsed = parse_source(src).unwrap(); + assert!(parsed.file.items.len() >= 3, "expected β‰₯3 items (use, struct, impl)"); + } + + #[test] + fn source_at_max_boundary_parses_successfully() { + // Craft a source exactly at the limit: fill with spaces so syn parses it. + let padding = " ".repeat(MAX_SOURCE_BYTES - 15); + let src = format!("fn f(){{}}{}", padding); + // May hit exactly or just under limit β€” accept both outcomes. + let _ = parse_source(&src); + } + + // ── ParseError Display ──────────────────────────────────────────────────── + + #[test] + fn parse_error_display_is_non_empty() { + let e = parse_source("").unwrap_err(); + assert!(!e.to_string().is_empty()); + } + + #[test] + fn syntax_error_display_mentions_syntax() { + let e = parse_source("fn {{{{").unwrap_err(); + let s = e.to_string(); + assert!( + s.contains("syntax") || s.contains("error") || s.contains("expected"), + "unexpected display: {s}" + ); + } +} diff --git a/tooling/sanctifier-core/tests/parser_contract_discovery_test.rs b/tooling/sanctifier-core/tests/parser_contract_discovery_test.rs new file mode 100644 index 00000000..021a7e2a --- /dev/null +++ b/tooling/sanctifier-core/tests/parser_contract_discovery_test.rs @@ -0,0 +1,257 @@ +//! Integration tests for `parser` + `contract_discovery`. +//! +//! These tests run against the canonical fixture files in `tests/fixtures/` +//! and verify end-to-end behaviour: read raw source β†’ validate β†’ parse β†’ discover. + +use sanctifier_core::{contract_discovery, parser}; +use std::fs; +use std::path::PathBuf; + +// ── Fixture helpers ──────────────────────────────────────────────────────────── + +fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures") + .join(name) +} + +fn read_fixture(name: &str) -> String { + fs::read_to_string(fixture_path(name)) + .unwrap_or_else(|_| panic!("fixture '{name}' should be readable")) +} + +// ── parser::parse_source ─────────────────────────────────────────────────────── + +#[test] +fn parse_source_succeeds_on_all_fixtures() { + for fixture in &[ + "minimal_contract.rs", + "auth_gap_contract.rs", + "clean_token.rs", + "overflow_contract.rs", + "reentrancy_contract.rs", + "multi_contract.rs", + "contract_with_storage_types.rs", + ] { + let source = read_fixture(fixture); + assert!( + parser::parse_source(&source).is_ok(), + "parse_source failed on fixture: {fixture}" + ); + } +} + +#[test] +fn parse_source_rejects_empty_string() { + let result = parser::parse_source(""); + assert!( + matches!(result, Err(parser::ParseError::Validation(_))), + "expected Validation error for empty string" + ); +} + +#[test] +fn parse_source_rejects_null_bytes() { + let result = parser::parse_source("fn foo() { let _ = \0; }"); + assert!( + matches!(result, Err(parser::ParseError::Validation(_))), + "expected Validation error for null-byte input" + ); +} + +#[test] +fn parse_source_rejects_invalid_rust_syntax() { + let result = parser::parse_source("this {{ is not rust"); + assert!( + matches!(result, Err(parser::ParseError::Syntax(_))), + "expected Syntax error for malformed input" + ); +} + +#[test] +fn parse_error_has_non_empty_display() { + let e = parser::parse_source("").unwrap_err(); + assert!(!e.to_string().is_empty()); +} + +// ── contract_discovery β€” minimal_contract.rs ────────────────────────────────── + +#[test] +fn minimal_contract_fixture_discovers_one_contract() { + let source = read_fixture("minimal_contract.rs"); + let file = parser::parse_source(&source).unwrap().file; + let contracts = contract_discovery::discover_contracts(&file); + + assert_eq!(contracts.len(), 1); + let c = &contracts[0]; + assert_eq!(c.struct_name, "MinimalContract"); + assert!(c.has_contract_attr, "MinimalContract must carry #[contract]"); + assert!(c.has_contractimpl, "MinimalContract must have a #[contractimpl] block"); +} + +#[test] +fn minimal_contract_exposes_exactly_one_public_function() { + let source = read_fixture("minimal_contract.rs"); + let file = parser::parse_source(&source).unwrap().file; + let contracts = contract_discovery::discover_contracts(&file); + + let fns: Vec<_> = contracts[0].public_functions().collect(); + assert_eq!(fns.len(), 1, "expected exactly one non-reserved public function"); + assert_eq!(fns[0].name, "ping"); + assert!(!fns[0].is_reserved); +} + +// ── contract_discovery β€” auth_gap_contract.rs ───────────────────────────────── + +#[test] +fn auth_gap_fixture_discovers_auth_gap_contract() { + let source = read_fixture("auth_gap_contract.rs"); + let file = parser::parse_source(&source).unwrap().file; + let contracts = contract_discovery::discover_contracts(&file); + + assert_eq!(contracts.len(), 1); + assert_eq!(contracts[0].struct_name, "AuthGapContract"); +} + +#[test] +fn auth_gap_fixture_discovers_two_public_functions() { + let source = read_fixture("auth_gap_contract.rs"); + let file = parser::parse_source(&source).unwrap().file; + let contracts = contract_discovery::discover_contracts(&file); + + let fns: Vec<_> = contracts[0].public_functions().collect(); + assert_eq!(fns.len(), 2, "expected store_user and has_user"); + let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect(); + assert!(names.contains(&"store_user")); + assert!(names.contains(&"has_user")); +} + +// ── contract_discovery β€” multi_contract.rs ──────────────────────────────────── + +#[test] +fn multi_contract_fixture_discovers_two_contracts() { + let source = read_fixture("multi_contract.rs"); + let file = parser::parse_source(&source).unwrap().file; + let contracts = contract_discovery::discover_contracts(&file); + + assert_eq!(contracts.len(), 2); + let names: Vec<&str> = contracts.iter().map(|c| c.struct_name.as_str()).collect(); + assert!(names.contains(&"TokenA"), "TokenA not found in {names:?}"); + assert!(names.contains(&"VaultB"), "VaultB not found in {names:?}"); +} + +#[test] +fn multi_contract_fixture_storage_types_on_each_contract() { + let source = read_fixture("multi_contract.rs"); + let file = parser::parse_source(&source).unwrap().file; + let contracts = contract_discovery::discover_contracts(&file); + + for c in &contracts { + assert_eq!( + c.storage_types.len(), + 2, + "contract {} should see both contracttype items", + c.struct_name + ); + let type_names: Vec<&str> = c.storage_types.iter().map(|t| t.name.as_str()).collect(); + assert!(type_names.contains(&"TokenKey")); + assert!(type_names.contains(&"VaultKey")); + } +} + +#[test] +fn multi_contract_token_a_has_three_public_functions() { + let source = read_fixture("multi_contract.rs"); + let file = parser::parse_source(&source).unwrap().file; + let contracts = contract_discovery::discover_contracts(&file); + + let token_a = contracts.iter().find(|c| c.struct_name == "TokenA").unwrap(); + let fns: Vec<_> = token_a.public_functions().collect(); + assert_eq!(fns.len(), 3, "expected initialize, balance, transfer"); +} + +#[test] +fn multi_contract_vault_b_has_two_public_functions() { + let source = read_fixture("multi_contract.rs"); + let file = parser::parse_source(&source).unwrap().file; + let contracts = contract_discovery::discover_contracts(&file); + + let vault = contracts.iter().find(|c| c.struct_name == "VaultB").unwrap(); + let fns: Vec<_> = vault.public_functions().collect(); + assert_eq!(fns.len(), 2, "expected deposit and total"); +} + +// ── contract_discovery β€” contract_with_storage_types.rs ────────────────────── + +#[test] +fn storage_types_fixture_discovers_one_contract() { + let source = read_fixture("contract_with_storage_types.rs"); + let file = parser::parse_source(&source).unwrap().file; + let contracts = contract_discovery::discover_contracts(&file); + + assert_eq!(contracts.len(), 1); + assert_eq!(contracts[0].struct_name, "Registry"); +} + +#[test] +fn storage_types_fixture_collects_both_contracttype_items() { + let source = read_fixture("contract_with_storage_types.rs"); + let file = parser::parse_source(&source).unwrap().file; + let contracts = contract_discovery::discover_contracts(&file); + + assert_eq!(contracts[0].storage_types.len(), 2); + let type_names: Vec<&str> = + contracts[0].storage_types.iter().map(|t| t.name.as_str()).collect(); + assert!(type_names.contains(&"DataKey"), "DataKey missing from {type_names:?}"); + assert!(type_names.contains(&"Config"), "Config missing from {type_names:?}"); +} + +#[test] +fn storage_types_fixture_constructor_is_reserved() { + let source = read_fixture("contract_with_storage_types.rs"); + let file = parser::parse_source(&source).unwrap().file; + let contracts = contract_discovery::discover_contracts(&file); + + let all = contracts[0].all_public_functions(); + let ctor = all.iter().find(|f| f.name == "__constructor").unwrap(); + assert!(ctor.is_reserved, "__constructor must be marked reserved"); +} + +#[test] +fn storage_types_fixture_non_reserved_public_functions() { + let source = read_fixture("contract_with_storage_types.rs"); + let file = parser::parse_source(&source).unwrap().file; + let contracts = contract_discovery::discover_contracts(&file); + + let fns: Vec<_> = contracts[0].public_functions().collect(); + assert_eq!(fns.len(), 3, "expected increment, get_count, admin"); + let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect(); + assert!(names.contains(&"increment")); + assert!(names.contains(&"get_count")); + assert!(names.contains(&"admin")); +} + +// ── Line number sanity ──────────────────────────────────────────────────────── + +#[test] +fn all_fixture_functions_have_positive_line_numbers() { + for fixture in &[ + "minimal_contract.rs", + "auth_gap_contract.rs", + "multi_contract.rs", + "contract_with_storage_types.rs", + ] { + let source = read_fixture(fixture); + let file = parser::parse_source(&source).unwrap().file; + let contracts = contract_discovery::discover_contracts(&file); + for c in &contracts { + for f in c.all_public_functions() { + assert!( + f.line > 0, + "fixture {fixture}: function '{}' has line 0", + f.name + ); + } + } + } +}