diff --git a/.github/workflows/docs-publish.yml b/.github/workflows/docs-publish.yml index f613f802..bf7a005a 100644 --- a/.github/workflows/docs-publish.yml +++ b/.github/workflows/docs-publish.yml @@ -63,7 +63,7 @@ jobs: run: touch target/doc/.nojekyll - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: target/doc diff --git a/Cargo.lock b/Cargo.lock index db27c50e..7e93b165 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,9 +116,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary" @@ -162,7 +162,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -254,7 +254,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex 1.3.0", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -452,7 +452,7 @@ dependencies = [ "num-bigint", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -526,9 +526,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -578,9 +578,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" dependencies = [ "clap_builder", "clap_derive", @@ -588,9 +588,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -600,9 +600,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.5" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b" dependencies = [ "clap", ] @@ -616,7 +616,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -851,7 +851,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -878,7 +878,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -912,7 +912,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -925,7 +925,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -936,7 +936,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -947,7 +947,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -991,7 +991,7 @@ checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1034,7 +1034,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1881,9 +1881,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.47.2" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" dependencies = [ "console 0.16.3", "once_cell", @@ -2384,7 +2384,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2547,7 +2547,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2577,7 +2577,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2691,7 +2691,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2715,9 +2715,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -2804,9 +2804,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2945,14 +2945,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -2962,9 +2962,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -2973,9 +2973,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -3183,7 +3183,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "syn 2.0.117", + "syn 2.0.119", "tar", "tempfile", "tokio", @@ -3204,13 +3204,14 @@ dependencies = [ "criterion", "insta", "proc-macro2", + "proptest", "quote", "regex", "serde", "serde_json", "serde_yaml", "soroban-sdk", - "syn 2.0.117", + "syn 2.0.119", "thiserror 1.0.69", "z3", ] @@ -3236,7 +3237,7 @@ version = "0.1.0" dependencies = [ "quote", "soroban-sdk", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3328,9 +3329,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3349,22 +3350,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.0", ] [[package]] @@ -3430,7 +3431,7 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3578,7 +3579,7 @@ dependencies = [ "itertools 0.11.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3655,7 +3656,7 @@ dependencies = [ "serde", "serde_json", "stellar-xdr", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3711,7 +3712,7 @@ dependencies = [ "soroban-spec", "soroban-spec-rust", "stellar-xdr", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3738,7 +3739,7 @@ dependencies = [ "sha2", "soroban-spec", "stellar-xdr", - "syn 2.0.117", + "syn 2.0.119", "thiserror 1.0.69", ] @@ -3835,9 +3836,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967" dependencies = [ "proc-macro2", "quote", @@ -3861,7 +3873,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3934,7 +3946,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3945,7 +3957,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4042,9 +4054,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" dependencies = [ "bytes", "libc", @@ -4065,7 +4077,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4220,7 +4232,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4567,7 +4579,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -4610,7 +4622,7 @@ checksum = "e925354648d2a4d1bf205412e36d520a800280622eef4719678d268e5d40e978" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4790,7 +4802,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4801,7 +4813,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5113,7 +5125,7 @@ dependencies = [ "heck", "indexmap 2.14.0", "prettyplease", - "syn 2.0.117", + "syn 2.0.119", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -5129,7 +5141,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -5206,7 +5218,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] @@ -5246,7 +5258,7 @@ checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5266,7 +5278,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] @@ -5306,7 +5318,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] diff --git a/tooling/sanctifier-core/Cargo.toml b/tooling/sanctifier-core/Cargo.toml index bc7fee51..e4b657b6 100644 --- a/tooling/sanctifier-core/Cargo.toml +++ b/tooling/sanctifier-core/Cargo.toml @@ -37,6 +37,7 @@ chrono = { version = "0.4", features = ["serde"] } criterion = "0.5.1" insta = { version = "1.40", features = ["json", "redactions"] } serde_json = "1.0" +proptest = "1.4" [[bench]] name = "benchmark" diff --git a/tooling/sanctifier-core/src/input_validation.rs b/tooling/sanctifier-core/src/input_validation.rs index e880fbda..66c019e4 100644 --- a/tooling/sanctifier-core/src/input_validation.rs +++ b/tooling/sanctifier-core/src/input_validation.rs @@ -26,6 +26,17 @@ pub const MAX_SOURCE_BYTES: usize = 10 * 1024 * 1024; /// Minimum accepted source size (1 byte). pub const MIN_SOURCE_BYTES: usize = 1; +/// Maximum accepted delimiter nesting depth. +/// +/// The analysis engine (and `syn` itself) parses and walks the AST with +/// recursive descent. Pathologically deep delimiter nesting — e.g. a few +/// hundred unmatched `(((((…` — drives that recursion deep enough to overflow +/// the thread stack and abort the process, a classic denial-of-service vector. +/// Legitimate Soroban contracts never nest delimiters more than a few dozen +/// levels, so we reject anything past this bound up front, turning a hard crash +/// into a graceful validation error. +pub const MAX_NESTING_DEPTH: usize = 256; + /// Structured validation error returned by every guard. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ValidationError { @@ -142,6 +153,46 @@ pub fn validate_path(path: &str) -> Result<(), ValidationError> { pub fn validate_source_all(source: &str) -> Result<(), ValidationError> { validate_source_size(source)?; validate_no_null_bytes(source)?; + validate_nesting_depth(source)?; + Ok(()) +} + +/// Validate that delimiter nesting stays within [`MAX_NESTING_DEPTH`]. +/// +/// Counts the running open-delimiter depth across `(`, `[`, and `{`. This is a +/// conservative, O(n) guard: it also counts delimiters inside string and +/// comment tokens, which only makes it stricter, never looser. It exists purely +/// to stop deeply nested input from overflowing the recursive parser/analyzer +/// stack before `syn` ever sees it. +/// +/// # Errors +/// - `EXCESSIVE_NESTING` — nesting depth exceeds [`MAX_NESTING_DEPTH`]. +pub fn validate_nesting_depth(source: &str) -> Result<(), ValidationError> { + let mut depth: usize = 0; + let mut max_depth: usize = 0; + for byte in source.bytes() { + match byte { + b'(' | b'[' | b'{' => { + depth += 1; + if depth > max_depth { + max_depth = depth; + } + } + b')' | b']' | b'}' => { + depth = depth.saturating_sub(1); + } + _ => {} + } + } + if max_depth > MAX_NESTING_DEPTH { + return Err(ValidationError { + code: "EXCESSIVE_NESTING", + message: format!( + "Source nests delimiters {max_depth} levels deep; maximum allowed is {MAX_NESTING_DEPTH}. \ + This is rejected to prevent recursive-descent stack exhaustion." + ), + }); + } Ok(()) } diff --git a/tooling/sanctifier-core/src/lib.rs b/tooling/sanctifier-core/src/lib.rs index 12243fb5..4451b99a 100644 --- a/tooling/sanctifier-core/src/lib.rs +++ b/tooling/sanctifier-core/src/lib.rs @@ -864,11 +864,11 @@ impl Analyzer { issues.push(EventIssue { event_name: event_name.clone(), issue_type: "inconsistent_schema".to_string(), + location: location.clone(), message: format!( "Event '{}' has inconsistent topic count. Previous: {}, Current: {}", event_name, prev_count, topic_count ), - location: location.clone(), }); } } @@ -889,8 +889,8 @@ impl Analyzer { issues.push(EventIssue { event_name, issue_type: "optimizable_topic".to_string(), - message: "Consider using symbol_short! for short topic names to save gas.".to_string(), location: format!("line {}", line_num + 1), + message: "Consider using symbol_short! for short topic names to save gas.".to_string(), }); } } diff --git a/tooling/sanctifier-core/tests/proptest_analysis.rs b/tooling/sanctifier-core/tests/proptest_analysis.rs new file mode 100644 index 00000000..aded5764 --- /dev/null +++ b/tooling/sanctifier-core/tests/proptest_analysis.rs @@ -0,0 +1,317 @@ +//! Property-based tests for the analysis engine. +//! +//! # The property +//! +//! > The analysis engine must never panic — on **any** input, valid Rust or +//! > not. For every input string, running the full rule set returns a result +//! > that is either `Ok(findings)` or `Err(parse_error)`; it never aborts the +//! > process, never overflows the stack, and never runs out of memory. +//! +//! This is the contract real users depend on: Sanctifier is run over arbitrary, +//! often half-written or machine-generated contract source, and a single panic +//! in a rule would take down an editor's LSP session or a CI run. +//! +//! # What this generates +//! +//! Three input distributions, mixed together: +//! +//! 1. **Structured Soroban source** — randomly assembled `#[contractimpl]` +//! blocks whose bodies are built from fragments the rules specifically look +//! for (`require_auth`, raw arithmetic, `unwrap`, storage writes, casts, +//! `panic!`, …). These mostly parse, so they exercise rule *logic*. +//! 2. **Random Rust-ish snippets** — free-form statements and items that may or +//! may not parse. +//! 3. **Arbitrary text** — fully random Unicode (including control characters +//! and embedded NULs), to fuzz the parser/validator boundary. +//! +//! # Crash regressions +//! +//! Any input proptest discovers that makes the engine panic is persisted by +//! proptest to `tests/proptest-regressions/` and is replayed on every +//! subsequent run. A curated seed corpus of historically tricky inputs lives in +//! [`regression_corpus`] and is also asserted panic-free. +//! +//! # Tuning +//! +//! The case count defaults to 10,000 and can be overridden with the +//! `PROPTEST_CASES` environment variable (used by the dedicated, time-boxed CI +//! job). + +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use proptest::prelude::*; + +use sanctifier_core::parser::{self, ParseError}; +use sanctifier_core::rules::RuleRegistry; +use sanctifier_core::{Analyzer, SanctifyConfig}; + +/// Default number of generated cases. Overridable via `PROPTEST_CASES`. +fn case_count() -> u32 { + std::env::var("PROPTEST_CASES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(10_000) +} + +/// Run the **whole** analysis engine over `source`. +/// +/// Returns: +/// - `Ok(finding_count)` when the source parses and the rules run. +/// - `Err(parse_error)` when the validator or parser rejects the input. +/// +/// It must do one or the other for every possible input — never panic. The +/// rule registry (`run_all`) is intentionally invoked *without* a panic guard +/// so that a panicking rule surfaces here as a test failure rather than being +/// silently swallowed. +fn run_engine(source: &str) -> Result { + // Validate and parse FIRST. This is both the contract (Ok/Err split) and a + // safety gate: `parse_source` runs the input-validation guards (size, NUL + // bytes, and delimiter-nesting depth) before any recursive rule walks the + // source, so pathologically deep input is rejected here instead of + // overflowing the parser/analyzer stack. + parser::parse_source(source)?; + + let registry = RuleRegistry::with_default_rules(); + let analyzer = Analyzer::new(SanctifyConfig::default()); + + // `run_all` runs the full built-in rule registry (every S0xx syntactic + // rule). `verify_sep41_interface` covers the SEP-41 analysis that lives + // outside the registry, so the property exercises the whole rule surface. + let mut findings = registry.run_all(source).len(); + findings += analyzer.verify_sep41_interface(source).issues.len(); + + Ok(findings) +} + +/// Assert that analyzing `source` does not panic, returning a short label of +/// which arm of the contract was taken (for diagnostics on shrink). +fn assert_no_panic(source: &str) -> Result<&'static str, String> { + let result = catch_unwind(AssertUnwindSafe(|| run_engine(source))); + match result { + Ok(Ok(_)) => Ok("ok(findings)"), + Ok(Err(_)) => Ok("err(parse_error)"), + Err(_) => Err(format!( + "analysis engine PANICKED on input ({} bytes): {:?}", + source.len(), + truncate(source, 400), + )), + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) +} + +// ── Generators ────────────────────────────────────────────────────────────── + +/// A short lowercase identifier. +fn arb_ident() -> impl Strategy { + proptest::string::string_regex("[a-z][a-z0-9_]{0,7}").expect("valid regex") +} + +/// A Soroban-flavored type name. +fn arb_type() -> impl Strategy { + prop_oneof![ + Just("i128"), + Just("u32"), + Just("i64"), + Just("u64"), + Just("u128"), + Just("bool"), + Just("Address"), + Just("MuxedAddress"), + Just("String"), + Just("Bytes"), + Just("Env"), + ] +} + +/// A single statement, drawn from fragments that the rules actively scan for. +fn arb_stmt() -> impl Strategy { + prop_oneof![ + Just("from.require_auth();".to_string()), + Just("spender.require_auth_for_args(().into_val(&env));".to_string()), + Just("let s = a + b;".to_string()), + Just("let d = a - b;".to_string()), + Just("let m = a * b;".to_string()), + Just("let q = a / b;".to_string()), + Just("env.storage().persistent().set(&key, &value);".to_string()), + Just("env.storage().instance().set(&key, &value);".to_string()), + Just("let t = env.ledger().timestamp();".to_string()), + Just("let narrowed = wide as u32;".to_string()), + Just("result.unwrap();".to_string()), + Just("maybe.expect(\"must exist\");".to_string()), + Just("panic!(\"boom\");".to_string()), + Just("client.invoke_contract(&cid, &sym, args);".to_string()), + Just("env.events().publish((topic,), data);".to_string()), + Just("for i in 0..n { total += i; }".to_string()), + arb_ident().prop_map(|i| format!("let {i} = 0i128;")), + (arb_ident(), arb_type()).prop_map(|(i, t)| format!("let {i}: {t} = Default::default();")), + ] +} + +/// A public method with random params and a random body. +fn arb_fn() -> impl Strategy { + ( + arb_ident(), + prop::collection::vec((arb_ident(), arb_type()), 0..4), + prop::collection::vec(arb_stmt(), 0..5), + prop::option::of(arb_type()), + ) + .prop_map(|(name, params, body, ret)| { + let params: Vec = std::iter::once("env: Env".to_string()) + .chain(params.into_iter().map(|(n, t)| format!("{n}: {t}"))) + .collect(); + let ret = ret.map(|t| format!(" -> {t}")).unwrap_or_default(); + let body = body.join("\n "); + format!( + " pub fn {name}({}){ret} {{\n {body}\n }}", + params.join(", ") + ) + }) +} + +/// A complete, mostly-parseable Soroban contract. +fn arb_contract() -> impl Strategy { + prop::collection::vec(arb_fn(), 1..4).prop_map(|fns| { + format!( + "#![no_std]\nuse soroban_sdk::{{contract, contractimpl, Address, Env, String}};\n\ + #[contract]\npub struct C;\n#[contractimpl]\nimpl C {{\n{}\n}}\n", + fns.join("\n") + ) + }) +} + +/// Free-form Rust-ish snippets (often invalid). +fn arb_snippet() -> impl Strategy { + prop::collection::vec(arb_stmt(), 0..12).prop_map(|stmts| stmts.join("\n")) +} + +/// Fully arbitrary text, including control characters and embedded NULs. +fn arb_garbage() -> impl Strategy { + prop::collection::vec(any::(), 0..600).prop_map(|cs| cs.into_iter().collect()) +} + +/// The full mixed input distribution. +fn arb_source() -> impl Strategy { + prop_oneof![ + 6 => arb_contract(), + 3 => arb_snippet(), + 2 => arb_garbage(), + 1 => any::>().prop_map(|b| String::from_utf8_lossy(&b).into_owned()), + ] +} + +proptest! { + #![proptest_config(ProptestConfig { + cases: case_count(), + max_shrink_iters: 2_000, + .. ProptestConfig::default() + })] + + /// The engine never panics on any generated input. This is the headline + /// property; it runs the full `PROPTEST_CASES` budget (10,000 by default). + #[test] + fn engine_never_panics(source in arb_source()) { + match assert_no_panic(&source) { + Ok(_) => {}, + Err(message) => prop_assert!(false, "{message}"), + } + } +} + +proptest! { + // The Ok/Err invariant is cheaper to establish and does not need the full + // budget, so cap it to keep the suite inside the CI time box. + #![proptest_config(ProptestConfig { + cases: case_count().min(1_000), + .. ProptestConfig::default() + })] + + /// Whenever the source parses cleanly, analysis yields `Ok(findings)`; + /// whenever it does not, analysis yields `Err(parse_error)`. The two are + /// mutually exclusive and exhaustive. + #[test] + fn ok_xor_parse_error(source in arb_source()) { + let parses = parser::parse_source(&source).is_ok(); + let outcome = catch_unwind(AssertUnwindSafe(|| run_engine(&source))); + prop_assert!(outcome.is_ok(), "engine panicked"); + let outcome = outcome.unwrap(); + prop_assert_eq!(parses, outcome.is_ok()); + } +} + +// ── Regression corpus ──────────────────────────────────────────────────────── +// +// Curated inputs that have historically stressed the parser or rules. Any crash +// input discovered by proptest should be distilled to its essence and added +// here (in addition to proptest's own `tests/proptest-regressions/` artifacts), +// so the fix is locked in by a fast, deterministic test. + +/// Inputs known to probe edge cases: empty, whitespace, control characters, +/// deeply nested delimiters, oversized tokens, unicode identifiers, and partial +/// Soroban constructs. +const REGRESSION_CORPUS: &[&str] = &[ + "", + " ", + "\n\t\r", + "\0", + "fn", + "}}}}}}}}}}", + "{{{{{{{{{{", + "impl", + "#[contractimpl]", + "#[contractimpl] impl C {}", + "pub fn transfer(env: Env, from: Address) { from.require_auth(); }", + "impl C { pub fn f(env: Env) -> i128 { a - b } }", + "let x = 9999999999999999999999999999999999999999;", + "fn f() { let y = i128::MAX as u8; }", + "//\u{0}comment with nul", + "🦀 contract 🦀", + "impl C { pub fn f() { f().f().f().f().f().f(); } }", +]; + +#[test] +fn regression_corpus_never_panics() { + for (i, input) in REGRESSION_CORPUS.iter().enumerate() { + assert_no_panic(input) + .unwrap_or_else(|message| panic!("regression #{i} reintroduced a crash: {message}")); + } + + // DISCOVERED BY PROPTEST: a deeply nested expression used to overflow the + // recursive parser/analyzer stack and abort the process (SIGABRT). It is now + // rejected by the `EXCESSIVE_NESTING` validation guard, so analysis returns + // a graceful `Err(parse_error)` instead of crashing. + let nested = format!("fn f() {{ {} }}", "(".repeat(500)); + assert_eq!( + assert_no_panic(&nested), + Ok("err(parse_error)"), + "deep nesting must be rejected gracefully, not crash the process" + ); + + // A long but shallow token stream (many independent statements) must be + // processed without crashing. + let long_body = "let v = 1i128;\n".repeat(3_000); + let long = format!("fn f() {{ {long_body} }}"); + assert_no_panic(&long).expect("long flat input must be handled, not crash"); +} + +/// Sanity check that the harness actually distinguishes the two contract arms. +#[test] +fn contract_arms_are_reachable() { + assert_eq!( + assert_no_panic("pub fn f(env: Env) -> i128 { 0 }"), + Ok("ok(findings)") + ); + assert_eq!( + assert_no_panic("this is not rust {{{"), + Ok("err(parse_error)") + ); +}