diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz-solana.yml similarity index 88% rename from .github/workflows/fuzz.yml rename to .github/workflows/fuzz-solana.yml index 5d9774bd..0e91baef 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz-solana.yml @@ -1,9 +1,12 @@ -name: Fuzz Testing +name: "Fuzz Testing: Solana" on: pull_request: types: [opened, synchronize, reopened, labeled] +env: + NIGHTLY_VERSION: nightly-2026-03-13 + jobs: fuzz: if: contains(github.event.pull_request.labels.*.name, 'fuzz') @@ -18,7 +21,7 @@ jobs: - name: Install Rust (nightly) uses: actions-rust-lang/setup-rust-toolchain@fb51252c7ba57d633bc668f941da052e410add48 # v1.13.0 with: - toolchain: nightly-2026-03-13 + toolchain: ${{ env.NIGHTLY_VERSION }} - name: Install cargo-fuzz run: cargo install cargo-fuzz --locked - name: Cache Rust dependencies @@ -50,12 +53,12 @@ jobs: - name: Fuzz fuzz_transaction_string (30s) id: fuzz_transaction_string continue-on-error: true - run: cargo +nightly-2026-03-13 fuzz run fuzz_transaction_string -- -max_total_time=30 + run: cargo +${{ env.NIGHTLY_VERSION }} fuzz run fuzz_transaction_string -- -max_total_time=30 working-directory: src/chain_parsers/visualsign-solana/fuzz - name: Fuzz fuzz_versioned_transaction (30s) id: fuzz_versioned_transaction continue-on-error: true - run: cargo +nightly-2026-03-13 fuzz run fuzz_versioned_transaction -- -max_total_time=30 + run: cargo +${{ env.NIGHTLY_VERSION }} fuzz run fuzz_versioned_transaction -- -max_total_time=30 working-directory: src/chain_parsers/visualsign-solana/fuzz - name: Label PR on fuzz failure env: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 452ceb67..7e4d6595 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,6 +23,7 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@fb51252c7ba57d633bc668f941da052e410add48 # v1.13.0 with: components: clippy, rustfmt + cache: false - name: Cache Rust dependencies uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 diff --git a/.github/workflows/proptest.yml b/.github/workflows/proptest-solana.yml similarity index 98% rename from .github/workflows/proptest.yml rename to .github/workflows/proptest-solana.yml index 40123c18..5e57d54e 100644 --- a/.github/workflows/proptest.yml +++ b/.github/workflows/proptest-solana.yml @@ -1,4 +1,4 @@ -name: Property Tests +name: "Property Tests: Solana" on: pull_request: diff --git a/.github/workflows/stagex.yml b/.github/workflows/stagex.yml index b655f784..9c54d214 100644 --- a/.github/workflows/stagex.yml +++ b/.github/workflows/stagex.yml @@ -12,16 +12,15 @@ on: - "images/**" - "Makefile" pull_request: - paths: - - ".github/**" - - "src/**" - - "images/**" - - "Makefile" + types: [labeled] workflow_dispatch: # Allows manual invocation jobs: parser: - name: Build parser images + name: Build ${{ matrix.target.label || matrix.target.name }} + if: >- + github.event_name != 'pull_request' || + github.event.label.name == 'stagex' runs-on: ubuntu-latest timeout-minutes: 60 permissions: @@ -37,6 +36,8 @@ jobs: matrix: target: - name: parser_app + - name: parser_cli + label: "parser_cli (Solana)" - name: parser_gateway steps: - name: Checkout sources @@ -47,7 +48,6 @@ jobs: uses: ./.github/actions/docker-setup with: dockerHub: ${{ secrets.DOCKERHUB_API_KEY }} - ghcr: ${{ secrets.GITHUB_TOKEN }} - name: Build ${{ matrix.target.name }} shell: bash @@ -58,6 +58,10 @@ jobs: run: | env -C out/${{ matrix.target.name }} tar -cf - . | docker load + - name: Login to GHCR + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + - name: Upload to GHCR run: | for tag in ${tags}; do diff --git a/Makefile b/Makefile index 071fcc3c..172737d9 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,10 @@ out/parser_app/index.json: \ $(shell git ls-files images/parser_app src) $(call build,parser_app) +out/parser_cli/index.json: \ + $(shell git ls-files images/parser_cli src) + $(call build,parser_cli) + out/parser_gateway/index.json: \ $(shell git ls-files images/parser_gateway src) $(call build,parser_gateway) diff --git a/images/parser_cli/Containerfile b/images/parser_cli/Containerfile new file mode 100644 index 00000000..fbef175e --- /dev/null +++ b/images/parser_cli/Containerfile @@ -0,0 +1,28 @@ +FROM stagex/pallet-rust:1.88.0@sha256:b9021d2b75eac64fe8b931d96dde63ef11792e5023cee77c3471ccc34a95a377 AS build + +# Rust configuration +ENV RUSTFLAGS='-C target-feature=+crt-static' +ENV CARGOFLAGS='--target x86_64-unknown-linux-musl --no-default-features --locked --release' + +# Directory for Rust artifacts +ENV RELEASE_DIR=/src/target/x86_64-unknown-linux-musl/release + +# Load Rust sources +ADD src /src +WORKDIR /src/ + +# pre-fetch all workspace deps; we need them to build with `--network=none` later +RUN cargo fetch + +WORKDIR /src/parser/cli +RUN --network=none <<-EOF + set -eu + cargo test ${CARGOFLAGS} --features solana + cargo build ${CARGOFLAGS} --features solana + mkdir -p /rootfs/usr/local/bin + mv ${RELEASE_DIR}/parser_cli /rootfs/usr/local/bin/ +EOF + +# Use busybox as a base so we can easily cp the pivot binary if needed +FROM stagex/core-busybox:1.36.1@sha256:cac5d773db1c69b832d022c469ccf5f52daf223b91166e6866d42d6983a3b374 AS package +COPY --from=build /rootfs/. . diff --git a/src/chain_parsers/visualsign-solana/tests/common/mod.rs b/src/chain_parsers/visualsign-solana/tests/common/mod.rs index bacdf5e8..5657747c 100644 --- a/src/chain_parsers/visualsign-solana/tests/common/mod.rs +++ b/src/chain_parsers/visualsign-solana/tests/common/mod.rs @@ -143,3 +143,21 @@ pub fn find_text(fields: &[AnnotatedPayloadField], label: &str) -> Option Option<(String, Idl)> { + let path = std::env::var("IDL_FILE").ok()?; + let json = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("IDL_FILE={path}: {e}")); + match decode_idl_data(&json) { + Ok(idl) => Some((json, idl)), + Err(e) => { + eprintln!("IDL_FILE={path}: skipping — decode failed: {e}"); + None + } + } +} diff --git a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs index d101ce72..55ae0a47 100644 --- a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -35,6 +35,8 @@ use solana_parser::{decode_idl_data, parse_instruction_with_idl}; use solana_parser_fuzz_core::proptest as arb; use std::sync::Arc; +mod common; + // parse_instruction_with_idl ignores the program_id parameter (_program_id); // use an obviously fake value to avoid confusion with real known programs. const TEST_PROGRAM_ID: &str = "deadbeef1234deadbeef5678deadbeef"; @@ -849,19 +851,7 @@ fn size_guard_vec_u64_over_budget() { // // See scripts/fuzz_all_idls.sh to run against all embedded IDLs in one pass. -fn load_idl_from_env() -> Option<(String, solana_parser::solana::structs::Idl)> { - let path = std::env::var("IDL_FILE").ok()?; - let json = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("IDL_FILE={path}: {e}")); - match decode_idl_data(&json) { - Ok(idl) => Some((json, idl)), - Err(e) => { - // IDL failed validation (e.g. duplicate type names, cyclic references). - // Skip these tests — they are not valid inputs for real_idl_* tests. - eprintln!("IDL_FILE={path}: skipping — decode failed: {e}"); - None - } - } -} +use common::load_idl_from_env; /// Crash-safety test against a real IDL loaded from IDL_FILE. /// diff --git a/src/chain_parsers/visualsign-solana/tests/real_idl_validation.rs b/src/chain_parsers/visualsign-solana/tests/real_idl_validation.rs new file mode 100644 index 00000000..868c7be4 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/tests/real_idl_validation.rs @@ -0,0 +1,88 @@ +//! Structural invariant tests for real production IDLs. +//! +//! These tests assert properties of the decoded IDL structure itself — +//! no random input generation, no proptest. They verify that `decode_idl_data` +//! produces well-formed IDLs from production JSON files. +//! +//! Run: `IDL_FILE=/path/to/idl.json cargo test --test real_idl_validation` +//! All IDLs: `scripts/fuzz_all_idls.sh` + +mod common; + +use common::load_idl_from_env; + +/// Every instruction in the decoded IDL must have a discriminator computed by +/// decode_idl_data (either provided explicitly or derived via Anchor's SHA256 +/// scheme). A missing discriminator means the instruction is unreachable. +#[test] +fn real_idl_all_instructions_have_discriminators() { + let Some((_, idl)) = load_idl_from_env() else { + return; + }; + for inst in &idl.instructions { + let disc = inst + .discriminator + .as_ref() + .unwrap_or_else(|| panic!("instruction '{}' has no discriminator", inst.name)); + assert_eq!( + disc.len(), + 8, + "instruction '{}' discriminator must be 8 bytes, got {}", + inst.name, + disc.len() + ); + } +} + +/// No two instructions in the IDL may share a discriminator — a collision would +/// make them indistinguishable at parse time. +#[test] +fn real_idl_discriminators_are_unique() { + let Some((_, idl)) = load_idl_from_env() else { + return; + }; + let mut seen: std::collections::HashMap, &str> = std::collections::HashMap::new(); + for inst in &idl.instructions { + let disc = inst + .discriminator + .as_ref() + .unwrap_or_else(|| panic!("instruction '{}' has no discriminator", inst.name)); + if let Some(existing) = seen.get(disc) { + panic!( + "discriminator collision between '{}' and '{}': {:?}", + existing, inst.name, disc + ); + } + seen.insert(disc.clone(), &inst.name); + } +} + +/// No two instructions may share a name — duplicate names make dispatch results +/// ambiguous and hint at an IDL construction error. +#[test] +fn real_idl_instruction_names_are_unique() { + let Some((_, idl)) = load_idl_from_env() else { + return; + }; + let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); + for inst in &idl.instructions { + assert!( + seen.insert(inst.name.as_str()), + "duplicate instruction name: '{}'", + inst.name + ); + } +} + +/// compute_idl_hash must be deterministic — the same JSON must produce the +/// same hash on every call. +#[test] +fn real_idl_idl_hash_is_stable() { + let Some((json, _)) = load_idl_from_env() else { + return; + }; + let h1 = solana_parser::compute_idl_hash(&json); + let h2 = solana_parser::compute_idl_hash(&json); + assert_eq!(h1, h2, "IDL hash must be deterministic"); + assert!(!h1.is_empty(), "IDL hash must not be empty"); +} diff --git a/src/rust-toolchain.toml b/src/rust-toolchain.toml index bb2af9b2..dd5e6333 100644 --- a/src/rust-toolchain.toml +++ b/src/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.88" +channel = "1.88.0" components = [ "rustfmt", "cargo", "clippy" ] profile = "minimal"