diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c362419 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: ci + +on: + push: + branches: + - 'master' + pull_request: + branches: + - 'master' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + components: rustfmt + + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + + - run: cargo build --locked --all-features -v + - run: cargo test --all-features -v + - run: cargo fmt --all -- --check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..46c663d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,58 @@ +name: release + +on: + push: + tags: + - 'v*' + +env: + IMAGE_NAME: monadfoundation/soteria + +jobs: + artifacts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + components: rustfmt + + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + + - run: cargo build --release --locked --all-features -v + + - name: Upload release version artifact + uses: actions/upload-artifact@v4 + with: + name: soteria + path: ./target/release/soteria + + docker: + runs-on: ubuntu-latest + steps: + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + push: true + sbom: true + provenance: mode=max + tags: ${{ steps.meta.outputs.tags }} diff --git a/.gitignore b/.gitignore index eee0792..722f871 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ /target .DS_Store -mocks/* +mocks/ diff --git a/Cargo.lock b/Cargo.lock index c477ea1..d4cbe2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,56 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -44,6 +94,52 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "const_format" version = "0.2.35" @@ -111,6 +207,12 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -147,6 +249,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.15" @@ -165,6 +273,18 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + [[package]] name = "parity-scale-codec" version = "3.7.5" @@ -294,18 +414,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" -[[package]] -name = "safe-hash-action" -version = "0.1.0" -dependencies = [ - "anyhow", - "hex", - "primitive-types", - "serde", - "serde_json", - "tiny-keccak", -] - [[package]] name = "serde" version = "1.0.228" @@ -349,12 +457,32 @@ dependencies = [ "serde_core", ] +[[package]] +name = "soteria" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "hex", + "owo-colors", + "primitive-types", + "serde", + "serde_json", + "tiny-keccak", +] + [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.110" @@ -435,12 +563,98 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.13" diff --git a/Cargo.toml b/Cargo.toml index d2d9ee9..5f94f9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,19 @@ [package] -name = "safe-hash-action" -authors = ["Monad Foundation"] +name = "soteria" +authors = ["QEDK "] license = "Apache-2.0" version = "0.1.0" edition = "2024" [dependencies] -anyhow = "1.0" -hex = "0.4" -primitive-types = "0.12" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -tiny-keccak = { version = "2", features = ["keccak"] } +# anyhow should not be pinned +anyhow = "1" +clap = { version = "=4.5.51", features = ["derive"] } +hex = "=0.4.3" +owo-colors = "=4.2.3" +primitive-types = "=0.12.2" +# serde should not be pinned +serde = { version = "1", features = ["derive"] } +# serde_json should not be pinned +serde_json = "1" +tiny-keccak = { version = "=2.0.2", features = ["keccak"] } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e561166 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM rust:alpine AS builder + +WORKDIR /app + +RUN apk add --no-cache musl-dev + +COPY . . + +RUN cargo build --profile release --bin soteria + +FROM gcr.io/distroless/static-debian12:nonroot AS runtime + +WORKDIR /data + +COPY --from=builder /app/target/release/soteria /usr/local/bin/soteria + +USER 65532:65532 + +ENTRYPOINT ["/usr/local/bin/soteria"] + +LABEL name="soteria" +LABEL description="A simple CLI tool for validating Safe transaction hashes from JSON log files" +LABEL org.opencontainers.image.title="soteria" +LABEL org.opencontainers.image.description="A simple CLI tool for validating Safe transaction hashes from JSON log files" +LABEL org.opencontainers.image.authors="QEDK " +LABEL org.opencontainers.image.vendor="Monad Foundation" +LABEL org.opencontainers.image.licenses="Apache-2.0" +LABEL org.opencontainers.image.source="https://github.com/monad-developers/soteria" +LABEL org.opencontainers.image.documentation="https://github.com/monad-developers/soteria/README.md" diff --git a/README.md b/README.md new file mode 100644 index 0000000..803f6b7 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# 🚦 soteria +A simple CLI tool that validates Safe transaction hashes in JSON log files. + +## Quickstart + +### Using CLI +```bash +cargo install --git https://github.com/monad-developers/soteria.git +soteria /path/to/your/logs/directory +``` + +### Using Docker +```bash +docker build -t soteria . +``` + +Replace `$(pwd)/src/mocks` with your directory of files to check: +```bash +docker run -v $(pwd)/src/mocks:/mnt/data soteria /mnt/data +``` diff --git a/src/main.rs b/src/main.rs index 3f7cea6..070b3cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,8 @@ -use anyhow::{anyhow, Context, Result}; +use anyhow::{Context, Result, anyhow}; +use clap::{ArgAction, Parser}; +use owo_colors::OwoColorize; use primitive_types::U256; use serde::Deserialize; -use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -10,18 +11,27 @@ const DOMAIN_TYPE: &str = "EIP712Domain(uint256 chainId,address verifyingContrac const SAFE_TX_TYPE: &str = "SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)"; fn main() -> Result<()> { - let args = CliArgs::from_env()?; - let mut files = collect_targets(&args.inputs)?; - if files.is_empty() { - println!("No JSON files found. Provide one or more directories or files to process."); + let args = CliArgs::parse(); + let resolved_inputs = args.resolved_inputs(); + let mut targets = collect_targets(&resolved_inputs, args.safe_address.as_deref())?; + if targets.is_empty() { + println!( + "{}", + "No JSON files found. Provide one or more directories or files to process.".yellow() + ); return Ok(()); } - files.sort(); + targets.sort_by(|a, b| a.path.cmp(&b.path)); let mut mismatches = Vec::new(); - for path in files { - match process_file(&path, args.chain_id, args.safe_address.as_deref()) { + for target in targets { + match process_file( + &target.path, + args.chain_id, + args.safe_address.as_deref(), + target.directory_safe.as_deref(), + ) { Ok(report) => { println!("{}", report.render_line()); if report.is_mismatch() { @@ -29,115 +39,75 @@ fn main() -> Result<()> { } } Err(err) => { - println!("{} :: error :: {}", path.display(), err); + let message = format!("{}\nerror\n{}", target.path.display(), err); + println!("{}", message.red()); } } } if !mismatches.is_empty() { - println!("\n{} mismatches detected.", mismatches.len()); - if args.fail_on_mismatch { - return Err(anyhow!("expected hash mismatch")); + let message = format!("{} mismatches detected.", mismatches.len()); + if args.ignore_error { + println!("{}", message.yellow()); + } else { + println!("{}", message.red()); + std::process::exit(1); } } Ok(()) } -#[derive(Clone, Debug)] +#[derive(Parser, Debug)] +#[command( + name = "soteria", + version, + author, + about = "Validate Safe transaction hashes for JSON log files" +)] struct CliArgs { - inputs: Vec, + /// Additional file or directory inputs + #[arg(short = 'i', long = "input", alias = "dir", value_name = "PATH", action = ArgAction::Append)] + input: Vec, + /// Positional paths to include + #[arg(value_name = "PATH")] + paths: Vec, + /// Chain ID for the Monad network + #[arg(long = "chain-id", default_value_t = 143)] chain_id: u64, + /// Override Safe address for all transactions + #[arg(long = "safe-address")] safe_address: Option, - fail_on_mismatch: bool, + /// Do not fail the process when mismatches occur + #[arg(long = "ignore-error", action = ArgAction::SetTrue)] + ignore_error: bool, } impl CliArgs { - fn from_env() -> Result { - let mut args = env::args().skip(1); + fn resolved_inputs(&self) -> Vec { let mut inputs = Vec::new(); - let mut chain_id = 143u64; - let mut safe_address = None; - let mut fail_on_mismatch = false; - - while let Some(arg) = args.next() { - match arg.as_str() { - "--input" | "--dir" | "-i" => { - let next = args.next().context("missing value for --input")?; - inputs.push(PathBuf::from(next)); - } - "--chain-id" => { - let next = args.next().context("missing value for --chain-id")?; - chain_id = next.parse().context("invalid chain id")?; - } - "--safe-address" => { - let next = args.next().context("missing value for --safe-address")?; - safe_address = Some(next); - } - "--fail-on-mismatch" => { - fail_on_mismatch = true; - } - "--help" | "-h" => { - print_help(); - std::process::exit(0); - } - other => { - inputs.push(PathBuf::from(other)); - } - } - } - - if inputs.is_empty() { - inputs.push(PathBuf::from("src/logs")); - inputs.push(PathBuf::from("src/accountConfigs")); - } - - Ok(Self { - inputs, - chain_id, - safe_address, - fail_on_mismatch, - }) + inputs.extend(self.input.iter().cloned()); + inputs.extend(self.paths.iter().cloned()); + inputs } } -fn print_help() { - println!("Compute Safe transaction hashes for JSON logs."); - println!("Usage: cargo run --release -- [options] [paths...]"); - println!("\nOptions:"); - println!(" --chain-id Override the chain id (default: 143)"); - println!(" --safe-address Fallback Safe address if files omit it"); - println!(" --fail-on-mismatch Return a non-zero exit status on mismatch"); - println!(" --input Directory or JSON file to include"); - println!(" -h, --help Show this help text"); -} - #[derive(Debug, Deserialize)] struct TransactionLog { - #[serde(rename = "address_to")] address_to: String, - #[serde(rename = "native_value", deserialize_with = "string_or_number")] + #[serde(default, deserialize_with = "string_or_number")] native_value: String, calldata: String, - #[serde(default)] operation: Option, - #[serde(rename = "current_nonce")] current_nonce: u64, - #[serde(default, rename = "safe_address")] - safe_address: Option, - #[serde(default, rename = "final_signer")] - final_signer: Option, - #[serde(default, rename = "expected_hash")] expected_hash: Option, - #[serde(default, rename = "safe_tx_gas", deserialize_with = "string_or_number_opt")] + #[serde(default, deserialize_with = "string_or_number_opt")] safe_tx_gas: Option, - #[serde(default, rename = "base_gas", deserialize_with = "string_or_number_opt")] + #[serde(default, deserialize_with = "string_or_number_opt")] base_gas: Option, - #[serde(default, rename = "gas_price", deserialize_with = "string_or_number_opt")] + #[serde(default, deserialize_with = "string_or_number_opt")] gas_price: Option, - #[serde(default, rename = "gas_token")] gas_token: Option, - #[serde(default, rename = "refund_receiver")] refund_receiver: Option, } @@ -150,35 +120,53 @@ struct Report { safe_address_used: String, } +#[derive(Clone, Debug)] +struct Target { + path: PathBuf, + directory_safe: Option, +} + impl Report { fn render_line(&self) -> String { + let path = self.path.display().to_string(); + let safe = format!("safe address = {}", self.safe_address_used); + match (&self.expected_hash, self.matched) { - (Some(_), Some(true)) => format!( - "{} :: hash={} (expected) :: safe={}", - self.path.display(), - self.computed_hash, - self.safe_address_used - ), - (Some(expected), Some(false)) => format!( - "{} :: hash={} (expected {}) :: safe={} :: mismatch", - self.path.display(), - self.computed_hash, - expected, - self.safe_address_used - ), - (Some(expected), None) => format!( - "{} :: hash={} (expected {}) :: safe={}", - self.path.display(), - self.computed_hash, - expected, - self.safe_address_used - ), - (None, _) => format!( - "{} :: hash={} :: safe={}", - self.path.display(), - self.computed_hash, - self.safe_address_used - ), + (Some(_), Some(true)) => { + let hash = format!("computed hash = {} (expected)", self.computed_hash); + format!("{}:\n{}\n{}", path.cyan(), hash.green(), safe.magenta()) + } + (Some(expected), Some(false)) => { + let hash = format!("computed hash = {} (mismatch)", self.computed_hash); + let expected_segment = format!("expected hash = {}", expected); + format!( + "{}:\n{}\n{}\n{}", + path.cyan(), + hash.red(), + expected_segment.yellow(), + safe.magenta(), + ) + } + (Some(expected), None) => { + let hash = format!("computed hash = {}", self.computed_hash); + let expected_segment = format!("expected hash = {}", expected); + format!( + "{}:\n{}\n{}\n{}", + path.cyan(), + hash.cyan(), + expected_segment.yellow(), + safe.magenta() + ) + } + (None, _) => { + let hash = format!("computed hash = {}", self.computed_hash); + format!( + "{}:\n{}\n{}\n", + path.cyan(), + hash.bright_white(), + safe.magenta() + ) + } } } @@ -187,47 +175,68 @@ impl Report { } } -fn collect_targets(inputs: &[PathBuf]) -> Result> { - let mut files = Vec::new(); +fn collect_targets(inputs: &[PathBuf], safe_address: Option<&str>) -> Result> { + let mut targets = Vec::new(); for input in inputs { if input.is_dir() { - for entry in fs::read_dir(input).with_context(|| format!("cannot read directory {}", input.display()))? { - let entry = entry?; - let path = entry.path(); - if path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("json")) { - if !is_empty_file(&path)? { - files.push(path); - } - } - } + gather_directory_targets(input, safe_address, &mut targets)?; } else if input.is_file() { - if input + if !input .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("json")) { - if !is_empty_file(input)? { - files.push(input.clone()); - } + continue; + } + + if is_empty_file(input)? { + continue; + } + + if is_account_config(input) { + // Account config files are not transaction payloads. + continue; } + + let directory_safe = if safe_address.is_none() { + if let Some(parent) = input.parent() { + if let Some(account_config) = find_account_config(parent)? { + load_account_config_safe(&account_config)? + } else { + None + } + } else { + None + } + } else { + None + }; + + targets.push(Target { + path: input.clone(), + directory_safe, + }); } } - Ok(files) + Ok(targets) } fn is_empty_file(path: &Path) -> Result { Ok(path.is_file() && fs::metadata(path)?.len() == 0) } -fn process_file(path: &Path, chain_id: u64, fallback_safe: Option<&str>) -> Result { - let data = fs::read_to_string(path).with_context(|| format!("unable to read {}", path.display()))?; +fn process_file( + path: &Path, + chain_id: u64, + safe_address: Option<&str>, + directory_safe: Option<&str>, +) -> Result { + let data = + fs::read_to_string(path).with_context(|| format!("unable to read {}", path.display()))?; let tx: TransactionLog = serde_json::from_str(&data) .with_context(|| format!("failed to parse {}", path.display()))?; - let safe_address_str = tx - .safe_address - .as_deref() - .or(fallback_safe) - .or(tx.final_signer.as_deref()) + let safe_address_str = safe_address + .or(directory_safe) .ok_or_else(|| anyhow!("no Safe address provided"))?; let computed = compute_safe_tx_hash(&tx, safe_address_str, chain_id)?; @@ -245,6 +254,86 @@ fn process_file(path: &Path, chain_id: u64, fallback_safe: Option<&str>) -> Resu }) } +fn gather_directory_targets( + dir: &Path, + cli_safe: Option<&str>, + targets: &mut Vec, +) -> Result<()> { + let mut entries = Vec::new(); + for entry in + fs::read_dir(dir).with_context(|| format!("cannot read directory {}", dir.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.is_file() + && path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("json")) + && !is_empty_file(&path)? + { + entries.push(path); + } + } + + let directory_safe = if cli_safe.is_none() { + if let Some(account_config) = entries.iter().find(|path| is_account_config(path)) { + load_account_config_safe(account_config)? + } else if let Some(account_config) = find_account_config(dir)? { + load_account_config_safe(&account_config)? + } else { + None + } + } else { + None + }; + + for path in entries { + if is_account_config(&path) { + continue; + } + + targets.push(Target { + path, + directory_safe: directory_safe.clone(), + }); + } + + Ok(()) +} + +fn is_account_config(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| name.eq_ignore_ascii_case("accountConfig.json")) + .unwrap_or(false) +} + +fn find_account_config(dir: &Path) -> Result> { + for entry in + fs::read_dir(dir).with_context(|| format!("cannot read directory {}", dir.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.is_file() && is_account_config(&path) && !is_empty_file(&path)? { + return Ok(Some(path)); + } + } + Ok(None) +} + +#[derive(Debug, Deserialize)] +struct AccountConfig { + safe_address: Option, +} + +fn load_account_config_safe(path: &Path) -> Result> { + let data = + fs::read_to_string(path).with_context(|| format!("unable to read {}", path.display()))?; + let config: AccountConfig = serde_json::from_str(&data) + .with_context(|| format!("failed to parse {}", path.display()))?; + Ok(config.safe_address) +} + fn compute_safe_tx_hash(tx: &TransactionLog, safe_address: &str, chain_id: u64) -> Result { let to = parse_address(&tx.address_to).context("invalid to address")?; let safe = parse_address(safe_address).context("invalid safe address")?;