Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .cargo-mutants.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ examine_globs = [
"src/utils/crypto.rs",
"src/commands/deploy.rs",
"src/utils/templates.rs",
# Contract testing infrastructure
"src/utils/mock_soroban.rs",
"src/utils/wasm_hash.rs",
"src/utils/contract_mocks.rs",
"src/utils/contract_testing.rs",
"src/utils/test_generator.rs",
]

# ── Skip ─────────────────────────────────────────────────────────────────────
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/fuzzing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ on:
- 'src/**'
- 'fuzz/**'
- 'tests/property_tests.rs'
- 'tests/contract_property_tests.rs'
- '.github/workflows/fuzzing.yml'
- 'Cargo.toml'
pull_request:
paths:
- 'src/**'
- 'fuzz/**'
- 'tests/property_tests.rs'
- 'tests/contract_property_tests.rs'
- '.github/workflows/fuzzing.yml'
- 'Cargo.toml'
# Allow manual dispatch with configurable fuzz duration.
Expand Down Expand Up @@ -66,6 +68,9 @@ jobs:
- name: Run property-based tests
run: cargo test --test property_tests --locked -- --test-threads=1

- name: Run contract property-based tests
run: cargo test --test contract_property_tests --locked -- --test-threads=1

- name: Run all tests (includes property tests)
run: cargo test --locked -- --test-threads=1

Expand Down Expand Up @@ -122,6 +127,11 @@ jobs:
- fuzz_wasm_hash
- fuzz_encrypted_bundle_parse
- fuzz_template_operations
# Contract fuzzing harnesses
- fuzz_wasm_validation
- fuzz_contract_invocation
- fuzz_contract_spec_parse
- fuzz_test_generator
steps:
- uses: actions/checkout@v4

Expand Down
60 changes: 58 additions & 2 deletions FUZZING_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ in the contract crate and use the same `PROPTEST_CASES` setting as this guide.
# Run property tests with default 256 cases per property.
cargo test --test property_tests

# Run contract property tests.
cargo test --test contract_property_tests

# Increase cases for a deeper search.
PROPTEST_CASES=5000 cargo test --test property_tests

Expand All @@ -63,6 +66,9 @@ cargo fuzz list --fuzz-dir fuzz
# Run a specific target for 60 seconds.
cargo fuzz run fuzz_validate_public_key --fuzz-dir fuzz -- -max_total_time=60

# Run a contract fuzz target.
cargo fuzz run fuzz_wasm_validation --fuzz-dir fuzz -- -max_total_time=60

# Run with a size cap (good for initial exploration).
cargo fuzz run fuzz_passphrase_strength --fuzz-dir fuzz \
-- -max_total_time=120 -max_len=1024
Expand Down Expand Up @@ -120,6 +126,7 @@ Additional property suites live in their own files:
|---|---|---|
| [`tests/config_property_tests.rs`](tests/config_property_tests.rs) | Config round trips | TOML/JSON serialization preserves every value; merging is identity-on-empty, idempotent, and overlay-wins; malformed combinations (unknown network, duplicate wallet, non-HTTP endpoint, unknown overlay key) are rejected |
| [`tests/wallet_import_property_tests.rs`](tests/wallet_import_property_tests.rs) | Wallet import/backup | The same invariants the wallet fuzz targets assert, run on stable in every `cargo test` sweep |
| [`tests/contract_property_tests.rs`](tests/contract_property_tests.rs) | Contract testing infrastructure | WASM validation, WASM hash computation, mock contract invocation, mock storage, mock addresses, and mock environment invariants |

### Writing new properties

Expand Down Expand Up @@ -164,6 +171,10 @@ macro that receives raw bytes and should **never panic** regardless of input.
| `fuzz_wallet_backup_parse` | Wallet backup documents: malformed JSON, truncated files, invalid StrKeys, oversized inputs, Unicode names |
| `fuzz_wallet_import_envelope` | Encrypted backup envelopes: base64 fields, salt/nonce lengths, truncated ciphertext, KDF parameters, plaintext/encrypted classification |
| `fuzz_wallet_backup_structured` | Near-valid backup documents built with `arbitrary::Arbitrary`, to reach the semantic checks the byte-level harness rarely hits |
| `fuzz_wasm_validation` | WASM binary validation: magic header, minimum size, panic-freedom |
| `fuzz_contract_invocation` | Mock contract client invocation: call counting, return/error determinism, reset |
| `fuzz_contract_spec_parse` | Contract test spec JSON parsing: malformed JSON, truncated docs, hostile Unicode |
| `fuzz_test_generator` | Test case generation from source: malformed Rust, empty files, arbitrary fragments |

### Wallet import & backup harnesses

Expand Down Expand Up @@ -203,6 +214,46 @@ The invariants asserted by the harnesses:
- **No misclassification** — a JSON document is never treated as an encrypted
bundle, which would prompt for a passphrase that does not exist.

### Contract fuzzing harnesses

The contract fuzzing harnesses target the Soroban contract testing infrastructure
in StarForge. These harnesses exercise the mock Soroban environment, WASM
validation, contract spec parsing, and test case generation — all of which
process inputs that could come from untrusted contract source files or test
specifications.

```bash
# WASM validation: magic header, minimum size, panic-freedom.
cargo fuzz run fuzz_wasm_validation --fuzz-dir fuzz -- -max_total_time=60

# Mock contract invocation: call counting, return/error determinism.
cargo fuzz run fuzz_contract_invocation --fuzz-dir fuzz -- -max_total_time=60

# Contract test spec JSON parsing: malformed JSON, truncated docs.
cargo fuzz run fuzz_contract_spec_parse --fuzz-dir fuzz -- -max_total_time=60

# Test case generation from source: malformed Rust, empty files.
cargo fuzz run fuzz_test_generator --fuzz-dir fuzz -- -max_total_time=60
```

Seed corpora ship under `fuzz/corpus/fuzz_wasm_validation/`,
`fuzz/corpus/fuzz_contract_spec_parse/`, and
`fuzz/corpus/fuzz_test_generator/`, covering valid WASM binaries, valid
contract test specs, and valid Rust source fragments respectively.

The invariants asserted by the contract harnesses:

- **Totality** — every input is handled gracefully; nothing panics.
- **WASM validation** — inputs shorter than 8 bytes or without the `\0asm`
magic header are rejected; valid headers with sufficient length are accepted.
- **Mock invocation** — call counts always match the number of invocations;
pre-configured return values and errors are returned deterministically;
errors take priority over return values; reset clears all state.
- **Spec parsing** — malformed JSON, truncated documents, and hostile Unicode
produce errors, never panics.
- **Test generation** — malformed Rust source, empty files, and arbitrary
fragments produce errors or empty results, never panics.

### Running a target

```bash
Expand Down Expand Up @@ -313,7 +364,7 @@ The fuzzing CI pipeline is defined in

| Job | Trigger | What it does |
|---|---|---|
| `property-tests` | Every push / PR | Runs `cargo test --test property_tests` with 2 000 cases |
| `property-tests` | Every push / PR | Runs `cargo test --test property_tests` and `cargo test --test contract_property_tests` with 2 000 cases |
| `fuzz-build` | Every push / PR | Compiles all fuzz targets (catches compilation errors) |
| `fuzz-smoke` | Every push / PR | 30-second smoke run per target in a matrix |
| `coverage` | Every push / PR | Generates LCOV + JSON; uploads to Codecov |
Expand All @@ -335,7 +386,7 @@ both systems:
### Property test

```rust
// In tests/property_tests.rs
// In tests/contract_property_tests.rs
proptest! {
#[test]
fn prop_my_contract_validates_input(amount in valid_amount_string()) {
Expand Down Expand Up @@ -377,3 +428,8 @@ because they process untrusted external input or handle cryptographic material:
| `check_passphrase_strength` | `utils/crypto.rs` | zxcvbn integration, minimum length gate |
| `compute_local_wasm_hash` | `commands/deploy.rs` | On-chain hash consistency |
| `validate_contract_id` | `utils/config.rs` | Contract address validation |
| `validate_wasm` | `utils/mock_soroban.rs` | WASM binary validation, magic header checks |
| `compute_wasm_hash` | `utils/wasm_hash.rs` | WASM hash computation, environment validation |
| `MockContractClient::invoke` | `utils/contract_mocks.rs` | Mock contract invocation, call logging |
| `load_contract_test_spec` | `utils/contract_testing.rs` | Contract test spec parsing (JSON/TOML) |
| `generate_from_source` | `utils/test_generator.rs` | Test case generation from Rust source |
31 changes: 31 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ hex = "0.4"
# JSON assembly for the structured wallet-backup harness.
serde_json = "1.0"

# Temp-file creation for harnesses that write fuzzer input to disk.
tempfile = "3.8"

# ── Fuzz targets ─────────────────────────────────────────────────────────────
# Each [[bin]] entry corresponds to a file under fuzz/fuzz_targets/.
# Run a specific target with:
Expand Down Expand Up @@ -101,3 +104,31 @@ name = "fuzz_wallet_backup_structured"
path = "fuzz_targets/fuzz_wallet_backup_structured.rs"
test = false
doc = false

# ── Contract fuzzing harnesses ───────────────────────────────────────────────
# These harnesses target Soroban contract testing infrastructure: WASM
# validation, mock contract invocation, spec parsing, and test generation.

[[bin]]
name = "fuzz_wasm_validation"
path = "fuzz_targets/fuzz_wasm_validation.rs"
test = false
doc = false

[[bin]]
name = "fuzz_contract_invocation"
path = "fuzz_targets/fuzz_contract_invocation.rs"
test = false
doc = false

[[bin]]
name = "fuzz_contract_spec_parse"
path = "fuzz_targets/fuzz_contract_spec_parse.rs"
test = false
doc = false

[[bin]]
name = "fuzz_test_generator"
path = "fuzz_targets/fuzz_test_generator.rs"
test = false
doc = false
1 change: 1 addition & 0 deletions fuzz/corpus/fuzz_contract_spec_parse/empty_spec.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
1 change: 1 addition & 0 deletions fuzz/corpus/fuzz_contract_spec_parse/malformed_spec.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"contract_id":"CA3C5F6A9B2D1E0F4C7A8B3D5E6F1A2C3B4D5E6F7A8B9C0D1E2F3A4B5C6D7E8F","environment":"testnet","test_cases":[{"name":"test_transfer","description":"Test basic transfer","inputs":{"from":"GABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789","to":"GABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789","amount":"100"},"expected":{"success":true}]
1 change: 1 addition & 0 deletions fuzz/corpus/fuzz_contract_spec_parse/valid_spec.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"contract_id":"CA3C5F6A9B2D1E0F4C7A8B3D5E6F1A2C3B4D5E6F7A8B9C0D1E2F3A4B5C6D7E8F","environment":"testnet","test_cases":[{"name":"test_transfer","description":"Test basic transfer","inputs":{"from":"GABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789","to":"GABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789","amount":"100"},"expected":{"success":true}}]}
1 change: 1 addition & 0 deletions fuzz/corpus/fuzz_test_generator/empty_source.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// empty source file for fuzzing
5 changes: 5 additions & 0 deletions fuzz/corpus/fuzz_test_generator/valid_source.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#[test]
fn test_transfer() {
let result = contract.transfer("alice", "bob", 100);
assert!(result.is_ok());
}
1 change: 1 addition & 0 deletions fuzz/corpus/fuzz_wasm_validation/valid_wasm.bin
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
\0asm\x01\x00\x00\x00\x01\x07\x01\x03\x65\x6e\x76\x00\x02\x00\x00
74 changes: 74 additions & 0 deletions fuzz/fuzz_targets/fuzz_contract_invocation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//! Fuzz harness: mock contract invocation.
//!
//! Exercises `MockContractClient::invoke` with structured, arbitrary inputs
//! generated via `arbitrary::Arbitrary`. This drives the mock contract
//! client's call-logging, response-lookup, and error-handling paths with
//! random function names, argument vectors, and caller identities.
//!
//! The harness verifies that:
//! - Invocation never panics for any input.
//! - Call counts are always consistent with the number of invocations.
//! - Pre-configured errors and return values are returned deterministically.
//!
//! Run with:
//! cargo fuzz run fuzz_contract_invocation

#![no_main]

use arbitrary::Arbitrary;
use libfuzzer_sys::fuzz_target;
use starforge::utils::contract_mocks::{MockAddress, MockContractClient};

/// Structured fuzzer input for contract invocation.
#[derive(Debug, Arbitrary)]
struct FuzzInvocation {
/// Function name to invoke (arbitrary string).
function: String,
/// Arguments as JSON values.
args: Vec<serde_json::Value>,
/// Whether to pre-configure a return value.
configure_return: bool,
/// Whether to pre-configure an error.
configure_error: bool,
/// Number of times to invoke before checking.
repeat: u8,
}

fuzz_target!(|input: FuzzInvocation| {
let contract = MockAddress::contract(1);
let client = MockContractClient::new(contract.clone());

// Optionally pre-configure a return value or error.
if input.configure_return {
client.mock_return(&input.function, serde_json::json!(42u64));
}
if input.configure_error {
client.mock_error(&input.function, "fuzz-error");
}

// Invoke the function `repeat` times — must never panic.
for _ in 0..input.repeat {
let caller = if input.repeat % 2 == 0 {
Some(MockAddress::account(1))
} else {
None
};
let _ = client.invoke(&input.function, input.args.clone(), caller, 100);
}

// Postcondition: call count must match the number of invocations.
let expected_count = input.repeat as usize;
assert_eq!(
client.call_count(&input.function),
expected_count,
"call count mismatch for function {:?}",
input.function
);

// Postcondition: total calls must match.
assert_eq!(
client.total_calls(),
expected_count,
"total call count mismatch"
);
});
27 changes: 27 additions & 0 deletions fuzz/fuzz_targets/fuzz_contract_spec_parse.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//! Fuzz harness: contract test spec parsing.
//!
//! Exercises `load_contract_test_spec` with arbitrary byte sequences written
//! to a temporary file. The spec parser is a trust boundary — the JSON/TOML
//! document comes from a file the user was handed — so it must be total:
//! malformed JSON, truncated documents, deeply nested structures, and
//! hostile Unicode all have to produce an error, never a panic.
//!
//! Run with:
//! cargo fuzz run fuzz_contract_spec_parse

#![no_main]

use libfuzzer_sys::fuzz_target;
use starforge::utils::contract_testing::load_contract_test_spec;
use std::io::Write;

fuzz_target!(|data: &[u8]| {
// Write the fuzzer input to a temp file with a .json extension so the
// parser dispatches to the JSON deserializer.
let mut tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
tmp.write_all(data).unwrap();
tmp.flush().unwrap();

// Must never panic — only return Ok or Err.
let _ = load_contract_test_spec(tmp.path());
});
25 changes: 25 additions & 0 deletions fuzz/fuzz_targets/fuzz_test_generator.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//! Fuzz harness: contract test case generation from source.
//!
//! Exercises `generate_from_source` with arbitrary Rust source fragments
//! written to a temporary file. The generator parses `pub fn` signatures
//! and produces test cases; it must be total — malformed source, empty
//! files, and hostile Unicode must produce an error, never a panic.
//!
//! Run with:
//! cargo fuzz run fuzz_test_generator

#![no_main]

use libfuzzer_sys::fuzz_target;
use starforge::utils::test_generator::generate_from_source;
use std::io::Write;

fuzz_target!(|data: &[u8]| {
// Write the fuzzer input to a temp file as Rust source.
let mut tmp = tempfile::NamedTempFile::with_suffix(".rs").unwrap();
tmp.write_all(data).unwrap();
tmp.flush().unwrap();

// Must never panic — only return Ok or Err.
let _ = generate_from_source(tmp.path());
});
20 changes: 20 additions & 0 deletions fuzz/fuzz_targets/fuzz_wasm_validation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//! Fuzz harness: WASM validation for Soroban contracts.
//!
//! Exercises `mock_soroban::validate_wasm` with arbitrary byte sequences.
//! The validator is a trust boundary — it runs on every WASM file before
//! the contract testing framework processes it — so it must be total:
//! no input should ever cause a panic, out-of-bounds read, or unbounded
//! allocation.
//!
//! Run with:
//! cargo fuzz run fuzz_wasm_validation

#![no_main]

use libfuzzer_sys::fuzz_target;
use starforge::utils::mock_soroban::validate_wasm;

fuzz_target!(|data: &[u8]| {
// Must never panic regardless of input size or content.
let _ = validate_wasm(data);
});
Loading