diff --git a/.gitignore b/.gitignore index 3aaf863b..72950d64 100644 --- a/.gitignore +++ b/.gitignore @@ -185,3 +185,6 @@ docker-compose.override.yml # Soroban test snapshots emitted by soroban-sdk testutils during `cargo test` test_snapshots/ + +# proptest shrink artifacts (regenerated on demand; not source) +*.proptest-regressions diff --git a/FUZZING_GUIDE.md b/FUZZING_GUIDE.md index 44c1475d..b36e3ff9 100644 --- a/FUZZING_GUIDE.md +++ b/FUZZING_GUIDE.md @@ -114,6 +114,13 @@ File: [`tests/property_tests.rs`](tests/property_tests.rs) | Structural invariants | Validated keys satisfy structural constraints independently (cross-check) | | `KdfOptions` | All-None is `is_default()`; any Some value is not | +Additional property suites live in their own files: + +| File | Property group | What it tests | +|---|---|---| +| [`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 | + ### Writing new properties Properties follow this pattern: @@ -154,6 +161,47 @@ macro that receives raw bytes and should **never panic** regardless of input. | `fuzz_wasm_hash` | SHA-256 WASM hash; determinism and format checks | | `fuzz_encrypted_bundle_parse` | Encrypted bundle parser via validate_secret_key | | `fuzz_template_operations` | Structured template inputs via `arbitrary::Arbitrary` | +| `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 | + +### Wallet import & backup harnesses + +`starforge wallet import --file` and `starforge backup restore` read files that +came from outside the tool, so their parsers are a trust boundary. All three +harnesses drive [`src/utils/wallet_import.rs`](src/utils/wallet_import.rs), +which is deliberately free of prompting, disk access, and config writes. + +```bash +# Byte-level: malformed JSON, truncated documents, byte soup. +cargo fuzz run fuzz_wallet_backup_parse -- -dict=fuzz/dicts/wallet_backup.dict + +# Envelope: base64 fields, truncated ciphertext, bad KDF parameters. +cargo fuzz run fuzz_wallet_import_envelope -- -dict=fuzz/dicts/wallet_backup.dict + +# Structured: near-valid documents that reach the semantic checks. +cargo fuzz run fuzz_wallet_backup_structured +``` + +Seed corpora ship under `fuzz/corpus/fuzz_wallet_backup_parse/` and +`fuzz/corpus/fuzz_wallet_import_envelope/`, covering a valid backup, a +watch-only backup, an empty wallet list, an unsupported version, a truncated +document, a name carrying a right-to-left override, 3/5/6-part envelopes, a +truncated ciphertext, and a non-base64 envelope. + +The invariants asserted by the harnesses: + +- **Totality** — every input returns a `WalletImportError`; nothing panics. +- **Size first** — the size limit is checked before the JSON parser runs, so an + oversized file cannot drive a large allocation. +- **Accepted implies valid** — an accepted backup has version `1`, at least one + wallet, no duplicate names, no control characters in a name, and a 56-character + `G…` public key on every entry. +- **Envelope shape** — an accepted envelope has a 16-byte salt, a 12-byte nonce, + a ciphertext of at least 16 bytes (one AES-GCM tag), and non-zero KDF + parameters. +- **No misclassification** — a JSON document is never treated as an encrypted + bundle, which would prompt for a passphrase that does not exist. ### Running a target diff --git a/README.md b/README.md index beb0bf23..dda8468a 100644 --- a/README.md +++ b/README.md @@ -536,6 +536,11 @@ StarForge has comprehensive documentation covering all aspects of the project: ### ?? Feature Documentation - **[TEMPLATE_MARKETPLACE.md](TEMPLATE_MARKETPLACE.md)** - Template marketplace feature - **[QUICK_START_TEMPLATES.md](QUICK_START_TEMPLATES.md)** - Template quick start guide +- **[docs/SIMULATION_RESOURCES.md](docs/SIMULATION_RESOURCES.md)** - CPU, memory, footprint, and minimum resource fees from simulation +- **[docs/CORRELATION_IDS.md](docs/CORRELATION_IDS.md)** - Correlating structured logs across one invocation +- **[docs/CONFIGURATION.md](docs/CONFIGURATION.md)** - Config parsing, overlay merging, and validation rules +- **[docs/WALLET_IMPORT_SECURITY.md](docs/WALLET_IMPORT_SECURITY.md)** - Limits enforced on untrusted wallet backups +- **[FUZZING_GUIDE.md](FUZZING_GUIDE.md)** - Property-based tests, fuzz targets, mutation testing ### ?? Navigation - **[DOCUMENTATION_INDEX.md](DOCUMENTATION_INDEX.md)** - Complete documentation index diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index 2bc5c63f..772523bf 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -9,6 +9,7 @@ Browse every top-level command and its most important flags. For wallet, templat | `-q, --quiet` | Suppress banner and decorative output | | `--log-format human\|json` | Structured log format (default: `human`) | | `--log-dir ` | Optional rotating log directory | +| `--correlation-id ` | Tie every log line of this invocation together (8–64 chars of `[A-Za-z0-9_-]`); defaults to `$STARFORGE_CORRELATION_ID` or a generated value — see [CORRELATION_IDS.md](CORRELATION_IDS.md) | | `-h, --help` | Command help | | `-V, --version` | CLI version | @@ -50,6 +51,10 @@ starforge tutorial next | `sign` | Sign a payload with a saved wallet | | `multisig` | Multisig helpers (create, add-signer, submit) | +`import --file` accepts a plaintext backup JSON or an encrypted bundle, detected +automatically. See [WALLET_IMPORT_SECURITY.md](WALLET_IMPORT_SECURITY.md) for the +limits enforced on untrusted backup files. + --- ## `multisig` @@ -96,6 +101,10 @@ starforge multisig notify proposal.json --message "Please sign the treasury paym **`deploy` flags:** `--network`, `--wallet`, `--optimize`, `--simulate`, `--yes`, `--execute` +`--simulate` and `--dry-run` print the simulated CPU, memory, and ledger +footprint alongside the minimum resource fee and a recommended fee that +includes a safety margin. See [SIMULATION_RESOURCES.md](SIMULATION_RESOURCES.md). + ```bash starforge deploy --wasm target/wasm32v1-none/release/token.wasm \ --wallet deployer --network testnet --simulate @@ -192,6 +201,27 @@ Coverage analysis tracks Soroban contract functions, line spans, branch paths, u --- +## `simulate` / `cost` — resource fees + +| Command | Purpose | +|---------|---------| +| `simulate resources --file ` | Report CPU, memory, footprint, and minimum resource fee from a saved `simulateTransaction` response | +| `simulate resources --contract --function ` | The same, simulated live against Soroban RPC | +| `cost resources --file ` | Price a simulation and check it against configured budgets (`--enforce` to gate CI) | + +Shared flags: `--margin ` (default `20`), `--inclusion-fee ` +(default `100`). `simulate resources` also takes `--json`. + +```bash +starforge simulate resources --file simulation.json --json +starforge simulate resources --contract CCPYZ... --function balance --network testnet +starforge cost resources --file simulation.json --network mainnet --enforce +``` + +Full reference: [SIMULATION_RESOURCES.md](SIMULATION_RESOURCES.md). + +--- + ## `advanced-perf` | Subcommand | Purpose | @@ -385,3 +415,7 @@ starforge my-plugin - [API_REFERENCE.md](../API_REFERENCE.md) — detailed per-command examples and output samples - [DEVELOPER_GUIDE.md](../DEVELOPER_GUIDE.md) — contributing and local development +- [SIMULATION_RESOURCES.md](SIMULATION_RESOURCES.md) — CPU, memory, footprint, and resource fees +- [CORRELATION_IDS.md](CORRELATION_IDS.md) — correlating structured logs across an invocation +- [CONFIGURATION.md](CONFIGURATION.md) — config parsing, overlays, and validation rules +- [WALLET_IMPORT_SECURITY.md](WALLET_IMPORT_SECURITY.md) — limits on untrusted wallet backups diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 00000000..f6e9cd2d --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,162 @@ +# Configuration: Parsing, Merging, and Serialization + +StarForge's configuration lives in `~/.config/starforge/` (a local SQLite store, +with `config.toml` still read for backwards compatibility). This document +covers the parts a developer or CI author needs: the pure API, the overlay +merge rules, and what makes a configuration invalid. + +--- + +## Pure API + +Everything in this list is a pure function — no filesystem, no database, no +environment. That is what makes configuration handling testable and what keeps +`cargo test` from mutating the developer's real config. + +| Function | Purpose | +|---|---| +| `config::parse_config_str(&str)` | Parse a configuration from TOML | +| `config::parse_config_json(&str)` | Parse a configuration from JSON | +| `config::to_toml_string(&Config)` | Serialize to TOML | +| `config::to_json_string(&Config)` | Serialize to JSON | +| `config::parse_overlay_str(&str)` | Parse a partial overlay from TOML | +| `config::merge_configs(base, overlay)` | Layer an overlay onto a base and validate | +| `config::validate_config(&Config)` | Validate a whole configuration | +| `config::validate_network_exists(&Config, &str)` | Check a network reference against *that* config | + +Round trips are exact in both formats: parsing what was serialized yields an +equal `Config`, and crossing formats (TOML → JSON → TOML) is stable. This is +enforced by [`tests/config_property_tests.rs`](../tests/config_property_tests.rs) +over hundreds of generated configurations. + +> **Field order matters in `Config`.** TOML requires a table's scalar values to +> be emitted before its sub-tables. All scalars are declared first, then tables, +> then arrays of tables. Moving a scalar below a table makes `to_toml_string` +> fail at runtime. Deserialization is by key, so the order can change without +> breaking existing files. + +--- + +## Overlays + +A `ConfigOverlay` is a partial configuration layered on top of a base — for a +project-local file, a CI environment, or a named profile. + +```toml +# overlay.toml +network = "mainnet" +telemetry_enabled = false + +[networks.staging] +horizon_url = "https://horizon-staging.example.com" +soroban_rpc_url = "https://rpc-staging.example.com" +``` + +### Precedence + +| Field | Rule | +|---|---| +| `network`, `telemetry_enabled`, `wallet_encryption` | Overlay wins when set; base kept otherwise | +| `feature_flags`, `plugin_trust`, `ai_telemetry` | Replaced **wholesale** when present | +| `networks` | Merged by key; an overlay entry replaces the base entry of that name | +| `wallets` | Appended; a duplicate name is an error | +| `version`, `install_id` | Always from the base — an overlay may not forge either | + +`feature_flags`, `plugin_trust`, and `ai_telemetry` are replaced rather than +field-merged on purpose: a partially specified trust policy that silently +inherits half of the base allowlist is a security footgun. + +Wallets are appended rather than overwritten because they hold key material. An +overlay whose wallet name collides with the base is rejected: + +``` +Overlay wallet 'deployer' already exists in the base configuration; rename it +or remove it from the overlay +``` + +### Properties + +Guaranteed, and tested over generated inputs: + +- **Identity** — merging an empty overlay changes nothing. +- **Idempotence** — applying the same overlay twice adds nothing the first + application did not already add. +- **Validation** — `merge_configs` validates the result, so a merge can never + produce a configuration that `save` would reject. + +### Unknown keys are rejected + +`ConfigOverlay` uses `deny_unknown_fields`. A typo is a hard error: + +``` +$ starforge ... # with `netwrok = "mainnet"` in the overlay +Failed to parse configuration overlay TOML: unknown field `netwrok` +``` + +A silently ignored `netwrok` key would leave a deploy pointed at the wrong +network. The main `Config` parser stays lenient about unknown keys so a file +written by a newer StarForge still loads. + +--- + +## What makes a configuration invalid + +`validate_config` rejects these *combinations* — each value may be well-formed +on its own: + +| Rejected | Why | +|---|---| +| Empty `version` | Schema version is required for migration | +| Empty or whitespace `network` | No active network | +| Active network not in `networks` and not built in | Dangling reference | +| Empty `networks` map | Nothing to connect to | +| A non-`http(s)` endpoint URL | Only HTTP(S) endpoints are supported | +| A wallet on an unknown network | Dangling reference | +| An invalid wallet name, public key, or secret key | Malformed entry | +| Two wallets with the same name | Ambiguous reference | +| An invalid plugin trust source | Malformed allowlist entry | + +Built-in networks (`testnet`, `mainnet`, `docker-testnet`) always resolve, even +if they are absent from the `networks` map. + +--- + +## Migration note + +`validate_network_exists` used to fall back to loading the on-disk +configuration when a network was missing from the `Config` it was handed. That +made validation depend on — and, through `load()`, *write to* — global state: +the same in-memory `Config` could validate differently on two machines, and +validating a value in a test opened and migrated the developer's real database. + +It is now pure: it consults the supplied `Config` and the built-in names, and +nothing else. + +**If you relied on the old behaviour** (calling `validate_network_exists` with a +partially populated `Config` and expecting it to consult the saved config), +load the configuration explicitly first and pass that in. `validate_network`, +which does read from disk by design, is unchanged. + +`validate_config` also now rejects duplicate wallet names. A configuration that +already contains duplicates will fail to save until one is renamed — previously +the duplicate silently shadowed the other on lookup. + +--- + +## Security + +- Wallet secrets in a configuration are stored either as plaintext StrKeys or + as encrypted bundles; `validate_config` accepts both shapes but never logs + either. Error messages quote the wallet name, not the key. +- An overlay cannot replace an existing wallet, so a hostile overlay file + cannot swap out a deployer key. +- An overlay cannot set `install_id`, which is used for deterministic + feature-flag bucketing. + +--- + +## See also + +- [docs/COMMAND_REFERENCE.md](COMMAND_REFERENCE.md) — the `config` command +- [FUZZING_GUIDE.md](../FUZZING_GUIDE.md) — running the property suites +- [tests/config_property_tests.rs](../tests/config_property_tests.rs) diff --git a/docs/CORRELATION_IDS.md b/docs/CORRELATION_IDS.md new file mode 100644 index 00000000..2876fcd3 --- /dev/null +++ b/docs/CORRELATION_IDS.md @@ -0,0 +1,152 @@ +# Correlation IDs in Structured Logs + +Every `starforge` invocation carries exactly one **correlation ID**. It is +attached to the root command span, so everything that happens underneath — +retries, network requests, plugin calls, deployment steps — logs the same ID. +One invocation can then be reconstructed from an aggregated log stream, even +when several StarForge processes run concurrently in the same pipeline. + +--- + +## Quick start + +```bash +# Generated automatically; JSON logs carry it on every record. +RUST_LOG=info starforge deploy --wasm ./token.wasm --log-format json + +# Supply your own, e.g. a CI job ID, so StarForge logs join your pipeline logs. +starforge deploy --wasm ./token.wasm --correlation-id "$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + +# Or through the environment. +export STARFORGE_CORRELATION_ID="pipeline-2026-07-29-a41f" +starforge deploy --wasm ./token.wasm --log-format json +``` + +Precedence: `--correlation-id` → `$STARFORGE_CORRELATION_ID` → freshly generated +(a UUIDv4 with hyphens stripped). + +--- + +## What the logs look like + +With `--log-format json`, each record carries the current span and the full +span stack, so the correlation ID is present on nested events too: + +```json +{ + "timestamp": "2026-07-29T12:01:04.882Z", + "level": "DEBUG", + "fields": { "message": "request sent" }, + "span": { "kind": "network_request", "method": "POST", + "url": "https://soroban-testnet.stellar.org", "correlation_id": "9f2c…" }, + "spans": [ + { "kind": "command", "command": "deploy", "correlation_id": "9f2c…" }, + { "kind": "deploy_step", "step": "upload-wasm", "index": 1, "total": 4, "correlation_id": "9f2c…" }, + { "kind": "network_request", "method": "POST", "url": "https://soroban-testnet.stellar.org", "correlation_id": "9f2c…" } + ] +} +``` + +With the default human format the span context is printed inline: + +``` +INFO command{kind=command command=deploy correlation_id=9f2c…}: starforge: command started +``` + +### Span kinds + +| Kind | Emitted by | Fields | +|---|---|---| +| `command` | Root span, entered once in `main` | `command`, `correlation_id` | +| `retry` | `correlation::retry_span` | `operation`, `attempt`, `max_attempts` | +| `network_request` | `correlation::network_span` | `method`, `url` (host + path only) | +| `plugin_call` | `correlation::plugin_span` | `plugin`, `entrypoint` | +| `deploy_step` | `correlation::deploy_step_span` | `step`, `index`, `total` | + +--- + +## Using it from code + +```rust +use starforge::utils::correlation; + +// Inside a retried operation: +let span = correlation::retry_span("horizon-submit", attempt, max_attempts); +let _entered = span.enter(); +tracing::warn!("attempt failed, backing off"); + +// Around an outbound request: +let span = correlation::network_span("POST", &rpc_url); +let _entered = span.enter(); +``` + +Do not build these spans by hand. The helpers run every attribute through +`correlation::sanitize` and every URL through `correlation::redact_url`, which +is what keeps secrets out of the log. + +--- + +## Validation rules + +A supplied correlation ID must be: + +- 8 to 64 characters long, +- made only of `A–Z`, `a–z`, `0–9`, `-`, and `_`. + +Leading and trailing whitespace is trimmed first. A value that fails validation +is a **fatal error** (exit code `2`), not a silent fallback — generating a +different ID would break exactly the log join the caller asked for. + +``` +$ starforge info --correlation-id "run 12" +Invalid correlation ID: correlation ID contains ' '; only letters, digits, '-' and '_' are allowed +``` + +--- + +## Security + +A correlation ID appears on every log line and is usually forwarded to a log +aggregator, so it must never carry anything sensitive. + +- **Generated IDs are random.** UUIDv4 — derived from nothing about the user, + the machine, or any key. +- **Supplied IDs are screened.** A value that looks like key material — a + Stellar `S…` secret seed, an encrypted `salt:nonce:ciphertext` bundle, or a + long mixed-case base64 blob — is rejected with + `correlation ID looks like key material`. +- **Span attributes are sanitized.** Values that look like secrets become + `[REDACTED]`; control characters are replaced with spaces so a crafted value + cannot forge an extra log record; attributes are truncated at 120 characters. +- **URLs are reduced to scheme, host, and path.** Query strings and + `user:password@` userinfo are dropped, not sanitized — API keys live in query + strings and there is no reason for a log to hold them. + +``` +https://ci:s3cr3t@rpc.example.com/soroban?apiKey=AKIA… → https://rpc.example.com/soroban +``` + +This complements the existing redaction helpers in +[`utils::logging`](../src/utils/logging.rs) — see +[SECURITY_LOGGING_GUIDE.md](../SECURITY_LOGGING_GUIDE.md). + +--- + +## Compatibility + +- **Additive.** Existing log consumers see new fields, no removed ones. +- JSON output now sets `with_current_span(true)` and `with_span_list(true)`, so + records gain `span` and `spans` members. A consumer that reads only + `fields.message` is unaffected. +- `--correlation-id` is a global flag, accepted before or after the subcommand. +- Re-entrant contexts (the `shell` REPL, plugin hosts) reuse the first installed + ID rather than minting a new one per inner command, so an interactive session + stays one correlated unit of work. + +--- + +## See also + +- [SECURITY_LOGGING_GUIDE.md](../SECURITY_LOGGING_GUIDE.md) — what may and may not be logged +- [SECURITY_LOGGING_BEST_PRACTICES.md](../SECURITY_LOGGING_BEST_PRACTICES.md) +- [docs/COMMAND_REFERENCE.md](COMMAND_REFERENCE.md) — global flags diff --git a/docs/SIMULATION_RESOURCES.md b/docs/SIMULATION_RESOURCES.md new file mode 100644 index 00000000..20a7970a --- /dev/null +++ b/docs/SIMULATION_RESOURCES.md @@ -0,0 +1,174 @@ +# Simulation Resource Fees + +Soroban charges for what a transaction *actually does*: CPU instructions burned, +linear memory touched, and the ledger entries read and written. The network's +`simulateTransaction` RPC is the only place those numbers come from — it runs +the invocation against a real ledger snapshot and reports a **minimum resource +fee** the transaction must carry to be accepted. + +StarForge surfaces those numbers in three places: + +| Command | What it does | +|---|---| +| `starforge simulate resources` | Report CPU, memory, footprint, and minimum resource fee, and derive a submittable fee | +| `starforge cost resources` | The same report, then check the resulting fee against configured budgets | +| `starforge deploy --simulate` / `--dry-run` | Print the resource report inline before the deploy is confirmed | + +--- + +## `starforge simulate resources` + +Two input modes. Exactly one is required. + +**Offline** — read a `simulateTransaction` response captured earlier (for +example by a CI job, or with `curl`): + +```bash +starforge simulate resources --file simulation.json +``` + +**Live** — simulate against a Soroban RPC endpoint: + +```bash +starforge simulate resources \ + --contract CCPYZ... \ + --function transfer \ + --arg GABC... --arg 1000 \ + --network testnet +``` + +| Flag | Default | Purpose | +|---|---|---| +| `--file ` | — | Saved `simulateTransaction` JSON response (conflicts with `--contract`) | +| `--contract ` | — | Contract to simulate live | +| `--function ` | — | Function to simulate (required with `--contract`) | +| `--arg ` | — | Function argument; repeat for multiple | +| `--arg-type ` | inferred | Type for the matching `--arg`; must be supplied for all or none | +| `--network ` | `testnet` | Network for live simulation | +| `--margin ` | `20` | Safety margin over the minimum resource fee (`0`–`1000`) | +| `--inclusion-fee ` | `100` | Per-operation inclusion (base) fee | +| `--json` | off | Emit the report as machine-readable JSON | + +Example output: + +``` +Simulated Transaction Resources +───────────────────────────────── +CPU instructions 1,274,180 +Memory (bytes) 1,275,072 +Footprint entries 3 total (2 read-only, 1 read-write) +Ledger read bytes 8,192 +Ledger write bytes 1,024 +Simulated at ledger 1234567 +───────────────────────────────── +Min resource fee 58,181 stroops (0.0058181 XLM) +Safety margin (20%) 11,636 stroops +Inclusion fee 100 stroops +Recommended fee 69,917 stroops (0.0069917 XLM) +``` + +### Why the margin exists + +Ledger state moves between simulation and submission. Submitting exactly +`minResourceFee` is a coin flip — a rent bump or a competing write in the same +ledger pushes the real cost above the simulated one and the transaction fails +with `txINSUFFICIENT_FEE`. The default 20% matches the Stellar CLI. Set +`--margin 0` only when you are replaying against a frozen ledger. + +--- + +## `starforge cost resources` + +Prices a saved simulation and checks it against the budgets configured with +`starforge cost budget set`: + +```bash +starforge cost resources --file simulation.json --network mainnet --enforce +``` + +| Flag | Default | Purpose | +|---|---|---| +| `--file ` | required | Saved `simulateTransaction` JSON response | +| `--network ` | `testnet` | Network whose budgets to check against | +| `--margin ` | `20` | Safety margin over the minimum resource fee | +| `--inclusion-fee ` | `100` | Per-operation inclusion fee | +| `--enforce` | off | Exit non-zero if the fee would exceed a budget | + +With `--enforce` this is a CI gate: the command exits non-zero when the +projected period spend crosses the configured limit, so a pipeline can refuse +to deploy without a human decision. + +--- + +## `starforge deploy` + +`--simulate` (and `--dry-run`, which implies it) now prints the resource +accounting alongside the fee: + +``` +Minimum Resource Fee 58181 stroops +CPU instructions 1274180 +Memory (bytes) 1275072 +Footprint 2 read-only, 1 read-write, 8192 B read, 1024 B written +Recommended fee 69917 stroops (0.0069917 XLM, includes a 20% margin) +``` + +If the RPC server returns no resource accounting, the extra lines are omitted +rather than filled with invented numbers, and the reason is reported as a +simulation warning. + +--- + +## Compatibility + +| RPC field | Protocol 20 | Protocol 21 / 22 | Behaviour | +|---|---|---|---| +| `minResourceFee` | yes | yes | Required. Without it the response is rejected. | +| `cost.cpuInsns` | yes | deprecated | Falls back to the instruction count in `transactionData`. | +| `cost.memBytes` | yes | deprecated | Reported as `not reported` with a warning. | +| `transactionData` | yes | yes | Source of the footprint. Absent → footprint omitted, fee still reported. | +| `restorePreamble` | no | yes | Its `minResourceFee` is added to the plan and a restore warning is printed. | + +Numeric fields are accepted as JSON numbers **or** JSON strings, because +stellar-rpc serialises 64-bit counters as strings. Values that are negative, +fractional, or non-numeric are rejected rather than coerced to zero. + +### Unsupported environments + +- **Not a Soroban RPC server** — a response with neither `minResourceFee` nor + `transactionData` is rejected with a message saying so, instead of reporting a + fabricated fee. +- **Host failure** (contract panic, bad auth, budget exceeded) — the `error` + from the response is surfaced and no fee is planned. +- **Transport failure** — a JSON-RPC `error` member is reported as an RPC error. + +--- + +## Migration note + +`SimulationResult::fee` previously reported `cost.cpuInsns`, an **instruction +count**, not a fee. Any script parsing that field as stroops was reading the +wrong number by roughly an order of magnitude. It now reports the RPC's +`minResourceFee`, falling back to `100000` stroops only when the server reports +no resource accounting at all. + +The struct gained an optional `resources` field. It is `#[serde(default)]`, so +previously serialised `SimulationResult` JSON still deserialises. + +--- + +## Security + +- Simulation responses are read from disk and from the network, so + `--file` inputs are capped at 8 MiB before the JSON parser runs. +- `transactionData` is decoded with an explicit XDR depth limit, so a nested + payload cannot drive unbounded recursion. +- Fee arithmetic is overflow-checked: a hostile or corrupted `minResourceFee` + produces an error rather than a wrapped total that looks affordable. + +--- + +## See also + +- [GAS_OPTIMIZATION_GUIDE.md](../GAS_OPTIMIZATION_GUIDE.md) — reducing the resources in the first place +- [docs/COMMAND_REFERENCE.md](COMMAND_REFERENCE.md) — every command and flag diff --git a/docs/WALLET_IMPORT_SECURITY.md b/docs/WALLET_IMPORT_SECURITY.md new file mode 100644 index 00000000..2ab53f6e --- /dev/null +++ b/docs/WALLET_IMPORT_SECURITY.md @@ -0,0 +1,147 @@ +# Wallet Import & Backup: Parser Hardening + +`starforge wallet import --file` and `starforge backup restore` read files that +came from outside the tool — a colleague's export, a CI artifact, a USB stick. +That makes their parsers a trust boundary, so they live in +[`src/utils/wallet_import.rs`](../src/utils/wallet_import.rs), separated from +prompting, disk access, and the config store, and are driven by three +continuously-run fuzz harnesses. + +--- + +## What is enforced + +### Size + +| Limit | Value | Checked | +|---|---|---| +| Backup document | 4 MiB | Before the JSON parser runs | +| Encrypted bundle | 8 MiB | Before base64 decoding | +| Wallets per backup | 1,000 | After parsing, before validation | +| Wallet name | 64 characters | Per entry | + +Size gates run *first*, so an oversized file cannot drive a large allocation +inside the JSON parser. + +### Backup documents + +- Must be a JSON object with `version`, `exported_at`, and a non-empty + `wallets` array. +- `version` must be exactly `"1"`. A newer version is refused with a message + naming both versions rather than being partially read. +- Wallet names must be unique within the file. +- Each entry's `public_key` must be a 56-character `G…` StrKey; a + `secret_key`, when present, must be a 56-character `S…` StrKey or a + well-formed encrypted bundle. +- `network` must be non-empty. + +### Encrypted bundles + +A bundle is `salt:nonce:ciphertext`, optionally followed by Argon2 parameters +(`:mem:iterations` or `:mem:iterations:parallelism`). + +| Field | Requirement | +|---|---| +| `salt` | Valid base64, decoding to exactly 16 bytes | +| `nonce` | Valid base64, decoding to exactly 12 bytes | +| `ciphertext` | Valid base64, at least 16 bytes (one AES-GCM tag) | +| `mem`, `iterations`, `parallelism` | Decimal `u32`, greater than zero | + +The structure is checked **before** the passphrase prompt, so a corrupt file +fails immediately instead of after an Argon2 key derivation. + +### Unicode + +Wallet names are rejected when they contain characters that are invisible or +that reorder rendering — they can make one wallet's name look exactly like +another's: + +| Range | Characters | +|---|---| +| `U+0000`–`U+001F`, `U+007F` | Control characters | +| `U+200B`–`U+200F` | Zero-width space through right-to-left mark | +| `U+202A`–`U+202E` | Bidirectional embedding and override | +| `U+2066`–`U+2069` | Bidirectional isolates | +| `U+00AD` | Soft hyphen | +| `U+FEFF` | Zero-width no-break space | + +Non-ASCII names are **accepted with a warning**, because earlier releases +allowed any Unicode alphanumeric and rejecting them outright would make old +backups unreadable. The warning flags the homograph risk (Cyrillic `а` renders +like Latin `a`): + +``` +⚠ wallet 'аlice' has a non-ASCII name, which can render identically to another name +``` + +### Error messages + +A rejection quotes the wallet name and the reason, never the key material — +error text lands in terminals, CI logs, and bug reports. + +--- + +## Behaviour changes + +### Encrypted bundles with custom Argon2 parameters now import correctly + +Encryption detection used to be `raw.matches(':').count() == 2`, which only +recognises the 3-part bundle. A backup encrypted with custom Argon2 parameters +(`starforge wallet export` after `--mem` / `--iterations` / `--parallelism`) has +5 or 6 parts, so it was handed to the JSON parser and failed with a misleading +`Invalid backup JSON format`. + +Detection now follows the bundle grammar. **If you have a backup that failed to +import with that error, retry it — no re-export is needed.** + +### A JSON document is never treated as a bundle + +Classification returns "plaintext" for anything starting with `{` or `[`, +regardless of how many colons the values contain. Previously a JSON document +with exactly two colons could trigger a passphrase prompt for a passphrase that +does not exist. + +### New rejections + +Files that previously imported and now do not: + +| Input | Now rejected because | +|---|---| +| A backup with more than 1,000 wallets | Above the per-file limit | +| A wallet name longer than 64 characters | Above the name limit | +| A wallet name containing bidi or zero-width characters | Deceptive name | +| A backup document above 4 MiB | Above the size limit | + +These were previously accepted, so a file hitting one of them was already +unusual. Split an oversized backup, or rename the offending wallet before +exporting. + +--- + +## Fuzzing + +```bash +cargo fuzz run fuzz_wallet_backup_parse -- -dict=fuzz/dicts/wallet_backup.dict +cargo fuzz run fuzz_wallet_import_envelope -- -dict=fuzz/dicts/wallet_backup.dict +cargo fuzz run fuzz_wallet_backup_structured +``` + +`cargo fuzz` needs a nightly toolchain. The same invariants run on stable in +[`tests/wallet_import_property_tests.rs`](../tests/wallet_import_property_tests.rs), +so every PR checks them without nightly: + +```bash +cargo test --test wallet_import_property_tests +PROPTEST_CASES=10000 cargo test --test wallet_import_property_tests +``` + +See [FUZZING_GUIDE.md](../FUZZING_GUIDE.md) for the harness inventory, the seed +corpora, and the invariants each target asserts. + +--- + +## See also + +- [WALLET_ENCRYPTION_FIX.md](../WALLET_ENCRYPTION_FIX.md) — the encryption format itself +- [SECURITY_LOGGING_GUIDE.md](../SECURITY_LOGGING_GUIDE.md) — what may be logged +- [docs/COMMAND_REFERENCE.md](COMMAND_REFERENCE.md) — the `wallet` command diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index c3dec7e5..b403903f 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -22,6 +22,9 @@ arbitrary = { version = "1.3", features = ["derive"] } sha2 = "0.10" hex = "0.4" +# JSON assembly for the structured wallet-backup harness. +serde_json = "1.0" + # ── Fuzz targets ───────────────────────────────────────────────────────────── # Each [[bin]] entry corresponds to a file under fuzz/fuzz_targets/. # Run a specific target with: @@ -80,3 +83,21 @@ name = "fuzz_template_operations" path = "fuzz_targets/fuzz_template_operations.rs" test = false doc = false + +[[bin]] +name = "fuzz_wallet_backup_parse" +path = "fuzz_targets/fuzz_wallet_backup_parse.rs" +test = false +doc = false + +[[bin]] +name = "fuzz_wallet_import_envelope" +path = "fuzz_targets/fuzz_wallet_import_envelope.rs" +test = false +doc = false + +[[bin]] +name = "fuzz_wallet_backup_structured" +path = "fuzz_targets/fuzz_wallet_backup_structured.rs" +test = false +doc = false diff --git a/fuzz/corpus/fuzz_wallet_backup_parse/bad_version.json b/fuzz/corpus/fuzz_wallet_backup_parse/bad_version.json new file mode 100644 index 00000000..f7f65034 --- /dev/null +++ b/fuzz/corpus/fuzz_wallet_backup_parse/bad_version.json @@ -0,0 +1 @@ +{"version":"9","exported_at":"","wallets":[]} \ No newline at end of file diff --git a/fuzz/corpus/fuzz_wallet_backup_parse/bidi_name.json b/fuzz/corpus/fuzz_wallet_backup_parse/bidi_name.json new file mode 100644 index 00000000..af60e054 --- /dev/null +++ b/fuzz/corpus/fuzz_wallet_backup_parse/bidi_name.json @@ -0,0 +1 @@ +{"version":"1","exported_at":"","wallets":[{"name":"al\u202Eice","public_key":"GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","secret_key":null,"network":"testnet","created_at":"","funded":false}]} \ No newline at end of file diff --git a/fuzz/corpus/fuzz_wallet_backup_parse/empty_wallets.json b/fuzz/corpus/fuzz_wallet_backup_parse/empty_wallets.json new file mode 100644 index 00000000..fb4ad11f --- /dev/null +++ b/fuzz/corpus/fuzz_wallet_backup_parse/empty_wallets.json @@ -0,0 +1 @@ +{"version":"1","exported_at":"","wallets":[]} \ No newline at end of file diff --git a/fuzz/corpus/fuzz_wallet_backup_parse/truncated.json b/fuzz/corpus/fuzz_wallet_backup_parse/truncated.json new file mode 100644 index 00000000..a128c55f --- /dev/null +++ b/fuzz/corpus/fuzz_wallet_backup_parse/truncated.json @@ -0,0 +1 @@ +{"version":"1","wallets":[ \ No newline at end of file diff --git a/fuzz/corpus/fuzz_wallet_backup_parse/valid_single.json b/fuzz/corpus/fuzz_wallet_backup_parse/valid_single.json new file mode 100644 index 00000000..bf3f07d6 --- /dev/null +++ b/fuzz/corpus/fuzz_wallet_backup_parse/valid_single.json @@ -0,0 +1 @@ +{"version":"1","exported_at":"2026-07-29T00:00:00Z","wallets":[{"name":"alice","public_key":"GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","secret_key":"SBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB","network":"testnet","created_at":"2026-07-29T00:00:00Z","funded":true}]} diff --git a/fuzz/corpus/fuzz_wallet_backup_parse/valid_watch_only.json b/fuzz/corpus/fuzz_wallet_backup_parse/valid_watch_only.json new file mode 100644 index 00000000..fc7ee775 --- /dev/null +++ b/fuzz/corpus/fuzz_wallet_backup_parse/valid_watch_only.json @@ -0,0 +1 @@ +{"version":"1","exported_at":"2026-07-29T00:00:00Z","wallets":[{"name":"ledger-1","public_key":"GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","secret_key":null,"network":"mainnet","created_at":"2026-07-29T00:00:00Z","funded":false}]} diff --git a/fuzz/corpus/fuzz_wallet_import_envelope/five_part.txt b/fuzz/corpus/fuzz_wallet_import_envelope/five_part.txt new file mode 100644 index 00000000..e41d6306 --- /dev/null +++ b/fuzz/corpus/fuzz_wallet_import_envelope/five_part.txt @@ -0,0 +1 @@ +AQEBAQEBAQEBAQEBAQEBAQ==:AgICAgICAgICAgIC:AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD:65536:3 \ No newline at end of file diff --git a/fuzz/corpus/fuzz_wallet_import_envelope/json_document.txt b/fuzz/corpus/fuzz_wallet_import_envelope/json_document.txt new file mode 100644 index 00000000..1e2a2c8c --- /dev/null +++ b/fuzz/corpus/fuzz_wallet_import_envelope/json_document.txt @@ -0,0 +1 @@ +{"version":"1"} \ No newline at end of file diff --git a/fuzz/corpus/fuzz_wallet_import_envelope/not_base64.txt b/fuzz/corpus/fuzz_wallet_import_envelope/not_base64.txt new file mode 100644 index 00000000..87cd4483 --- /dev/null +++ b/fuzz/corpus/fuzz_wallet_import_envelope/not_base64.txt @@ -0,0 +1 @@ +!!!:@@@:### \ No newline at end of file diff --git a/fuzz/corpus/fuzz_wallet_import_envelope/six_part.txt b/fuzz/corpus/fuzz_wallet_import_envelope/six_part.txt new file mode 100644 index 00000000..fd40dd15 --- /dev/null +++ b/fuzz/corpus/fuzz_wallet_import_envelope/six_part.txt @@ -0,0 +1 @@ +AQEBAQEBAQEBAQEBAQEBAQ==:AgICAgICAgICAgIC:AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD:65536:3:4 \ No newline at end of file diff --git a/fuzz/corpus/fuzz_wallet_import_envelope/three_part.txt b/fuzz/corpus/fuzz_wallet_import_envelope/three_part.txt new file mode 100644 index 00000000..eba1ef81 --- /dev/null +++ b/fuzz/corpus/fuzz_wallet_import_envelope/three_part.txt @@ -0,0 +1 @@ +AQEBAQEBAQEBAQEBAQEBAQ==:AgICAgICAgICAgIC:AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD \ No newline at end of file diff --git a/fuzz/corpus/fuzz_wallet_import_envelope/truncated_ciphertext.txt b/fuzz/corpus/fuzz_wallet_import_envelope/truncated_ciphertext.txt new file mode 100644 index 00000000..4a4ac689 --- /dev/null +++ b/fuzz/corpus/fuzz_wallet_import_envelope/truncated_ciphertext.txt @@ -0,0 +1 @@ +AQEBAQEBAQEBAQEBAQEBAQ==:AgICAgICAgICAgIC:AwMDAwMDAwM= \ No newline at end of file diff --git a/fuzz/corpus/fuzz_wallet_import_envelope/two_part.txt b/fuzz/corpus/fuzz_wallet_import_envelope/two_part.txt new file mode 100644 index 00000000..388b7cb7 --- /dev/null +++ b/fuzz/corpus/fuzz_wallet_import_envelope/two_part.txt @@ -0,0 +1 @@ +AQEBAQEBAQEBAQEBAQEBAQ==:AgICAgICAgICAgIC \ No newline at end of file diff --git a/fuzz/dicts/wallet_backup.dict b/fuzz/dicts/wallet_backup.dict new file mode 100644 index 00000000..80fa4209 --- /dev/null +++ b/fuzz/dicts/wallet_backup.dict @@ -0,0 +1,69 @@ +# Wallet import / backup fuzzing dictionary (issue #697) +# +# Tokens libFuzzer can splice into inputs to reach the semantic checks in +# `starforge::utils::wallet_import` instead of dying in the JSON tokenizer. +# +# Usage: +# cargo fuzz run fuzz_wallet_backup_parse \ +# -- -dict=fuzz/dicts/wallet_backup.dict + +# ── JSON scaffolding ───────────────────────────────────────────────────────── +"{" +"}" +"[" +"]" +"null" +"true" +"false" +"\"\"" + +# ── Backup document keys ───────────────────────────────────────────────────── +"\"version\"" +"\"exported_at\"" +"\"wallets\"" +"\"name\"" +"\"public_key\"" +"\"secret_key\"" +"\"network\"" +"\"created_at\"" +"\"funded\"" + +# ── Version values (accepted and rejected) ─────────────────────────────────── +"\"1\"" +"\"0\"" +"\"2\"" +"\"1.0\"" + +# ── Network names ──────────────────────────────────────────────────────────── +"\"testnet\"" +"\"mainnet\"" +"\"docker-testnet\"" + +# ── StrKey shapes ──────────────────────────────────────────────────────────── +"GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +"SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +"G" +"S" +"C" + +# ── Encrypted bundle separators and fields ─────────────────────────────────── +":" +"::" +":::" +"AQEBAQEBAQEBAQEBAQEBAQ==" +"AgICAgICAgICAgIC" +"AwMDAwMDAwMDAwMDAwMDAw==" +"65536" +"3" +"4" +"0" +"4294967295" + +# ── Unicode edge cases ─────────────────────────────────────────────────────── +"‮" +"​" +"" +"­" +"⁦" +"а" +"😀" diff --git a/fuzz/fuzz_targets/fuzz_wallet_backup_parse.rs b/fuzz/fuzz_targets/fuzz_wallet_backup_parse.rs new file mode 100644 index 00000000..48474de5 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_wallet_backup_parse.rs @@ -0,0 +1,85 @@ +//! Fuzz harness: wallet backup document parsing (issue #697). +//! +//! Drives `parse_wallet_backup` with arbitrary bytes. The parser is a trust +//! boundary — the document comes from a file the user was handed — so it must +//! be total: malformed JSON, truncated documents, invalid StrKeys, oversized +//! inputs, and hostile Unicode all have to produce an error, never a panic and +//! never an unbounded allocation. +//! +//! Run with: +//! cargo fuzz run fuzz_wallet_backup_parse +//! +//! With the JSON dictionary: +//! cargo fuzz run fuzz_wallet_backup_parse -- -dict=fuzz/dicts/wallet_backup.dict + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use starforge::utils::wallet_import::{ + parse_wallet_backup, WalletImportError, MAX_BACKUP_BYTES, MAX_WALLETS_PER_BACKUP, + MAX_WALLET_NAME_LEN, WALLET_BACKUP_VERSION, +}; + +fuzz_target!(|data: &[u8]| { + // Exercise both well-formed UTF-8 and byte soup: a backup file can contain + // anything, and `String::from_utf8_lossy` is what a real read would hit. + let owned; + let input = match std::str::from_utf8(data) { + Ok(s) => s, + Err(_) => { + owned = String::from_utf8_lossy(data).into_owned(); + &owned + } + }; + + match parse_wallet_backup(input) { + Err(WalletImportError::TooLarge { bytes, limit }) => { + assert!( + bytes > limit, + "TooLarge reported for an input within the limit" + ); + assert_eq!(limit, MAX_BACKUP_BYTES); + } + Err(_) => { + // Every other rejection is fine; the point is that it is a + // rejection rather than a panic. + } + Ok(parsed) => { + // Anything accepted must satisfy every documented invariant. + assert_eq!(parsed.backup.version, WALLET_BACKUP_VERSION); + assert!( + !parsed.backup.wallets.is_empty(), + "an accepted backup must contain at least one wallet" + ); + assert!( + parsed.backup.wallets.len() <= MAX_WALLETS_PER_BACKUP, + "accepted backup exceeds the wallet limit" + ); + + let mut names = std::collections::HashSet::new(); + for wallet in &parsed.backup.wallets { + assert!( + names.insert(wallet.name.as_str()), + "accepted backup contains duplicate wallet names" + ); + assert!(!wallet.name.is_empty(), "accepted an empty wallet name"); + assert!( + wallet.name.chars().count() <= MAX_WALLET_NAME_LEN, + "accepted an overlong wallet name" + ); + assert!( + !wallet.name.chars().any(|c| c.is_control()), + "accepted a wallet name containing control characters" + ); + assert!( + wallet.public_key.len() == 56 && wallet.public_key.starts_with('G'), + "accepted a wallet with a malformed public key" + ); + assert!( + !wallet.network.trim().is_empty(), + "accepted a wallet with an empty network" + ); + } + } + } +}); diff --git a/fuzz/fuzz_targets/fuzz_wallet_backup_structured.rs b/fuzz/fuzz_targets/fuzz_wallet_backup_structured.rs new file mode 100644 index 00000000..ed32b24d --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_wallet_backup_structured.rs @@ -0,0 +1,105 @@ +//! Fuzz harness: structurally-guided wallet backup parsing (issue #697). +//! +//! Random bytes almost never form valid JSON, so `fuzz_wallet_backup_parse` +//! spends most of its budget in the JSON parser. This harness instead builds +//! *near-valid* backup documents from structured input, which drives the +//! semantic checks — version, duplicate names, StrKey shape, Unicode names, +//! wallet count — that the byte-level harness rarely reaches. +//! +//! Run with: +//! cargo fuzz run fuzz_wallet_backup_structured + +#![no_main] + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; +use starforge::utils::wallet_import::{parse_wallet_backup, MAX_WALLETS_PER_BACKUP}; + +#[derive(Arbitrary, Debug)] +struct FuzzEntry { + name: String, + /// Chooses between a well-formed key, a truncated one, and arbitrary text. + key_shape: u8, + key_body: String, + secret: Option, + network: String, + funded: bool, +} + +#[derive(Arbitrary, Debug)] +struct FuzzBackup { + version: String, + exported_at: String, + entries: Vec, +} + +fn public_key_for(entry: &FuzzEntry) -> String { + let body: String = entry + .key_body + .chars() + .filter(|c| matches!(c, 'A'..='Z' | '2'..='7')) + .take(55) + .collect(); + + match entry.key_shape % 3 { + // Well formed: padded out to exactly 55 payload characters. + 0 => format!("G{:A<55}", body), + // Truncated. + 1 => format!("G{}", body), + // Whatever the fuzzer produced. + _ => entry.key_body.clone(), + } +} + +fuzz_target!(|input: FuzzBackup| { + // Keep documents bounded; the size limit itself is covered by the + // byte-level harness. + if input.entries.len() > MAX_WALLETS_PER_BACKUP + 1 { + return; + } + + let entries: Vec = input + .entries + .iter() + .map(|entry| { + let secret = match &entry.secret { + Some(s) => serde_json::Value::String(s.clone()), + None => serde_json::Value::Null, + }; + serde_json::json!({ + "name": entry.name, + "public_key": public_key_for(entry), + "secret_key": secret, + "network": entry.network, + "created_at": "2026-07-29T00:00:00Z", + "funded": entry.funded, + }) + .to_string() + }) + .collect(); + + // Assembled textually so the wallet array can hold entries the typed + // structs would not allow (duplicate keys, odd ordering) — the parser must + // cope with all of it. + let document = format!( + r#"{{"version":{},"exported_at":{},"wallets":[{}]}}"#, + serde_json::Value::String(input.version.clone()), + serde_json::Value::String(input.exported_at.clone()), + entries.join(",") + ); + + // Must never panic; any accepted document must hold its invariants. + if let Ok(parsed) = parse_wallet_backup(&document) { + let mut names = std::collections::HashSet::new(); + for wallet in &parsed.backup.wallets { + assert!( + names.insert(wallet.name.as_str()), + "accepted duplicate wallet names" + ); + assert!( + wallet.public_key.starts_with('G') && wallet.public_key.len() == 56, + "accepted a malformed public key" + ); + } + } +}); diff --git a/fuzz/fuzz_targets/fuzz_wallet_import_envelope.rs b/fuzz/fuzz_targets/fuzz_wallet_import_envelope.rs new file mode 100644 index 00000000..af3eea70 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_wallet_import_envelope.rs @@ -0,0 +1,75 @@ +//! Fuzz harness: encrypted wallet backup envelope parsing (issue #697). +//! +//! Drives `classify_payload` and `parse_encrypted_envelope` with arbitrary +//! input. These run *before* any passphrase is requested, so they see fully +//! untrusted bytes: truncated ciphertext, non-base64 fields, wrong salt/nonce +//! lengths, absurd KDF parameters, and Unicode. +//! +//! Run with: +//! cargo fuzz run fuzz_wallet_import_envelope +//! +//! With the bundle dictionary: +//! cargo fuzz run fuzz_wallet_import_envelope -- -dict=fuzz/dicts/wallet_backup.dict + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use starforge::utils::wallet_import::{ + classify_payload, parse_encrypted_envelope, PayloadKind, GCM_TAG_LEN, NONCE_LEN, SALT_LEN, +}; + +fuzz_target!(|data: &[u8]| { + let owned; + let input = match std::str::from_utf8(data) { + Ok(s) => s, + Err(_) => { + owned = String::from_utf8_lossy(data).into_owned(); + &owned + } + }; + + // Classification must never panic and must be deterministic. + let kind = classify_payload(input); + assert_eq!( + kind, + classify_payload(input), + "classification is not deterministic" + ); + + // A JSON document must never be mistaken for an encrypted bundle: doing so + // would prompt the user for a passphrase they do not have. + let trimmed = input.trim(); + if trimmed.starts_with('{') || trimmed.starts_with('[') { + assert_eq!( + kind, + PayloadKind::Plaintext, + "a JSON document was classified as an encrypted bundle" + ); + } + + match parse_encrypted_envelope(input) { + Err(_) => {} + Ok(envelope) => { + // Structural guarantees the decryptor relies on. + assert_eq!(envelope.salt.len(), SALT_LEN); + assert_eq!(envelope.nonce.len(), NONCE_LEN); + assert!( + envelope.ciphertext.len() >= GCM_TAG_LEN, + "accepted a ciphertext too short to carry an auth tag" + ); + for param in [envelope.mem_cost, envelope.iterations, envelope.parallelism] + .into_iter() + .flatten() + { + assert!(param > 0, "accepted a zero KDF parameter"); + } + // Anything structurally valid must also have been classified as a + // bundle, or the CLI would never reach this code path. + assert_eq!( + kind, + PayloadKind::Encrypted, + "a structurally valid bundle was classified as plaintext" + ); + } + } +}); diff --git a/src/commands/cost.rs b/src/commands/cost.rs index f5606286..ba2a8021 100644 --- a/src/commands/cost.rs +++ b/src/commands/cost.rs @@ -10,6 +10,7 @@ use crate::utils::{ cost_estimation as ce, cost_management as cm, print as p, + simulation_resources as sr, }; use anyhow::Result; use clap::Subcommand; @@ -60,6 +61,26 @@ pub enum CostCommands { #[arg(long)] network: Option, }, + /// Price a `simulateTransaction` response: report CPU, memory, footprint, + /// and the minimum resource fee, then check it against configured budgets + Resources { + /// Path to a saved `simulateTransaction` JSON response + #[arg(long)] + file: PathBuf, + /// Network whose budgets the resulting fee is checked against + #[arg(long, default_value = "testnet")] + network: String, + /// Safety margin, in percent, applied to the minimum resource fee + #[arg(long, default_value_t = sr::DEFAULT_FEE_MARGIN_PERCENT)] + margin: u32, + /// Per-operation inclusion (base) fee in stroops + #[arg(long, default_value_t = sr::DEFAULT_INCLUSION_FEE_STROOPS)] + inclusion_fee: u64, + /// Exit with a non-zero status if the fee would exceed a budget + /// (suitable for gating CI/CD pipelines) + #[arg(long)] + enforce: bool, + }, } #[derive(Subcommand)] @@ -106,9 +127,117 @@ pub async fn handle(cmd: CostCommands) -> Result<()> { CostCommands::Forecast { network, periods } => forecast(network, periods), CostCommands::CompareNetworks { wasm, networks } => compare_networks(wasm, networks), CostCommands::Report { network } => report(network), + CostCommands::Resources { + file, + network, + margin, + inclusion_fee, + enforce, + } => resources(file, network, margin, inclusion_fee, enforce), } } +/// Outcome of checking a simulated resource fee against one budget. +#[derive(Debug, Clone, PartialEq)] +pub struct ResourceBudgetCheck { + pub network: String, + pub limit_xlm: f64, + pub spent_xlm: f64, + pub projected_spent_xlm: f64, + pub would_exceed: bool, +} + +/// Project the period spend for each budget on `network` once a transaction +/// costing `fee_xlm` is submitted. +/// +/// Pure function — takes budgets and history as slices so the decision logic is +/// testable without touching disk. +pub fn check_resource_fee_against( + fee_xlm: f64, + network: &str, + budgets: &[cm::Budget], + history: &[ce::CostHistoryEntry], +) -> Vec { + budgets + .iter() + .filter(|b| b.network == network) + .map(|b| { + let status = cm::budget_status_for(b, history); + let projected = status.spent_xlm + fee_xlm; + ResourceBudgetCheck { + network: b.network.clone(), + limit_xlm: b.limit_xlm, + spent_xlm: status.spent_xlm, + projected_spent_xlm: projected, + would_exceed: projected > b.limit_xlm, + } + }) + .collect() +} + +fn resources( + file: PathBuf, + network: String, + margin: u32, + inclusion_fee: u64, + enforce: bool, +) -> Result<()> { + config::validate_file_path(&file, Some("json"))?; + config::validate_network(&network)?; + + let raw = std::fs::read_to_string(&file).map_err(|e| { + anyhow::anyhow!( + "Failed to read simulation response {}: {}", + file.display(), + e + ) + })?; + + let resources = sr::parse_simulation_response_str(&raw).map_err(|e| anyhow::anyhow!("{}", e))?; + let plan = + sr::plan_fee(&resources, margin, inclusion_fee).map_err(|e| anyhow::anyhow!("{}", e))?; + + sr::render_report(&resources, &plan); + + let fee_xlm = plan.recommended_fee_xlm(); + let budgets = cm::load_budgets()?; + let history = ce::load_cost_history()?; + let checks = check_resource_fee_against(fee_xlm, &network, &budgets, &history); + + println!(); + if checks.is_empty() { + p::info(&format!( + "No budget configured for '{}'. Set one with: starforge cost budget set --network {} --amount ", + network, network + )); + return Ok(()); + } + + let mut exceeded = false; + for check in &checks { + p::kv_accent("Budget network", &check.network); + p::kv("Limit", &format!("{:.7} XLM", check.limit_xlm)); + p::kv("Spent this period", &format!("{:.7} XLM", check.spent_xlm)); + p::kv("This transaction", &format!("{:.7} XLM", fee_xlm)); + p::kv( + "Projected", + &format!("{:.7} XLM", check.projected_spent_xlm), + ); + if check.would_exceed { + exceeded = true; + p::warn("This transaction would exceed the budget for the current period."); + } else { + p::success("Within budget."); + } + } + + if exceeded && enforce { + anyhow::bail!("Budget enforcement failed: simulated resource fee exceeds the budget"); + } + + Ok(()) +} + fn budget(action: BudgetAction) -> Result<()> { match action { BudgetAction::Set { @@ -376,3 +505,47 @@ fn report(network: Option) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod resource_budget_tests { + use super::*; + + fn budget(network: &str, limit_xlm: f64) -> cm::Budget { + cm::Budget { + network: network.to_string(), + period: cm::BudgetPeriod::Monthly, + limit_xlm, + label: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + } + } + + #[test] + fn reports_within_budget_for_a_small_fee() { + let checks = + check_resource_fee_against(0.001, "testnet", &[budget("testnet", 1.0)], &[]); + assert_eq!(checks.len(), 1); + assert!(!checks[0].would_exceed); + assert!((checks[0].projected_spent_xlm - 0.001).abs() < f64::EPSILON); + } + + #[test] + fn ignores_budgets_for_other_networks() { + let checks = + check_resource_fee_against(5.0, "mainnet", &[budget("testnet", 1.0)], &[]); + assert!(checks.is_empty()); + } + + #[test] + fn fee_exactly_at_the_limit_is_not_an_overrun() { + let checks = check_resource_fee_against(1.0, "testnet", &[budget("testnet", 1.0)], &[]); + assert!(!checks[0].would_exceed); + } + + #[test] + fn fee_above_the_limit_is_flagged() { + let checks = + check_resource_fee_against(1.000_000_1, "testnet", &[budget("testnet", 1.0)], &[]); + assert!(checks[0].would_exceed); + } +} diff --git a/src/commands/deploy.rs b/src/commands/deploy.rs index 4188d349..75c683a9 100644 --- a/src/commands/deploy.rs +++ b/src/commands/deploy.rs @@ -5,7 +5,8 @@ use crate::utils::{ self, last_successful, record_deployment, set_contract_id, set_duration, update_status, DeployRecord, DeployStatus, }, - deployment_monitor, horizon, notifications, optimizer, print as p, soroban, wallet_signer, + deployment_monitor, horizon, notifications, optimizer, print as p, simulation_resources, + soroban, wallet_signer, wasm_hash::{compute_wasm_hash, BuildEnvironment}, }; @@ -94,6 +95,54 @@ fn is_wasm_above_size_limit(wasm_size_kb: f64) -> bool { wasm_size_kb > SOROBAN_WASM_LIMIT_KB } +/// Print the CPU / memory / footprint accounting that simulation reported, +/// plus the fee we recommend actually submitting. +/// +/// Silently does nothing when the RPC server returned no resource accounting: +/// the caller has already printed the fallback fee and any errors, and an +/// invented footprint would be worse than none. +fn report_simulation_resources(simulation: &soroban::SimulationResult, indent: &str) { + let Some(resources) = simulation.resources.as_ref() else { + return; + }; + + match resources.cpu_instructions { + Some(cpu) => p::kv(&format!("{}CPU instructions", indent), &cpu.to_string()), + None => p::kv(&format!("{}CPU instructions", indent), "not reported"), + } + match resources.memory_bytes { + Some(mem) => p::kv(&format!("{}Memory (bytes)", indent), &mem.to_string()), + None => p::kv(&format!("{}Memory (bytes)", indent), "not reported"), + } + if let Some(fp) = resources.footprint.as_ref() { + p::kv( + &format!("{}Footprint", indent), + &format!( + "{} read-only, {} read-write, {} B read, {} B written", + fp.read_only_entries, fp.read_write_entries, fp.read_bytes, fp.write_bytes + ), + ); + } + if resources.requires_restore() { + p::warn(&format!( + "{}Archived ledger entries must be restored before this deploy can succeed", + indent + )); + } + + if let Some(plan) = simulation.fee_plan(simulation_resources::DEFAULT_FEE_MARGIN_PERCENT) { + p::kv_accent( + &format!("{}Recommended fee", indent), + &format!( + "{} stroops ({:.7} XLM, includes a {}% margin)", + plan.recommended_fee_stroops, + plan.recommended_fee_xlm(), + plan.margin_percent + ), + ); + } +} + /// Compute the Soroban WASM hash (SHA-256 over raw `.wasm` file bytes) /// and return it as a 64-character lowercase hex string. /// @@ -228,9 +277,14 @@ async fn run_dry_run( Ok(simulation) => { estimated_fee_stroops = Some(simulation.fee); p::kv( - " Estimated fee", - &format!("{} stroops ({:.7} XLM)", simulation.fee, simulation.fee as f64 / 10_000_000.0), + " Minimum resource fee", + &format!( + "{} stroops ({:.7} XLM)", + simulation.fee, + simulation.fee as f64 / 10_000_000.0 + ), ); + report_simulation_resources(&simulation, " "); if !simulation.errors.is_empty() { for error in &simulation.errors { warnings.push(format!("RPC simulation warning: {}", error)); @@ -476,7 +530,11 @@ pub async fn handle(args: DeployArgs) -> Result<()> { p::info("Simulating deploy transaction via Soroban RPC..."); match soroban::simulate_deploy_transaction(&wasm_hash, &args.network, wallet).await { Ok(simulation) => { - p::kv("Estimated Fee", &format!("{} stroops", simulation.fee)); + p::kv( + "Minimum Resource Fee", + &format!("{} stroops", simulation.fee), + ); + report_simulation_resources(&simulation, ""); if !simulation.errors.is_empty() { for error in &simulation.errors { p::warn(&format!("Simulation error: {}", error)); diff --git a/src/commands/simulate.rs b/src/commands/simulate.rs index 9fdfa0c8..634767dd 100644 --- a/src/commands/simulate.rs +++ b/src/commands/simulate.rs @@ -10,6 +10,7 @@ use crate::utils::network_simulator::{ simulator::NetworkSimulator, }; use crate::utils::print as p; +use crate::utils::simulation_resources; use anyhow::Result; use clap::{Args, Subcommand}; use colored::*; @@ -62,6 +63,10 @@ pub enum SimulateCommands { /// Show simulator status (ledger, accounts, contracts) Status, + /// Report CPU, memory, footprint, and minimum resource fee from a + /// `simulateTransaction` response, and derive a submittable fee + Resources(ResourcesArgs), + /// Deploy a simulated contract Deploy(DeploySimArgs), /// Invoke a simulated contract function @@ -235,6 +240,51 @@ pub struct DeploySimArgs { pub name: Option, } +/// Arguments for `starforge simulate resources`. +/// +/// Either `--file` (offline: a saved `simulateTransaction` response) or +/// `--contract` + `--function` (live: simulate against a Soroban RPC endpoint) +/// must be supplied. +#[derive(Args)] +pub struct ResourcesArgs { + /// Path to a saved `simulateTransaction` JSON response (offline planning, + /// e.g. a response captured in CI) + #[arg(long, conflicts_with = "contract")] + pub file: Option, + + /// Contract ID to simulate against a live Soroban RPC endpoint + #[arg(long)] + pub contract: Option, + + /// Contract function to simulate (required with --contract) + #[arg(long)] + pub function: Option, + + /// Function argument (repeat for multiple arguments) + #[arg(long = "arg", value_name = "VALUE")] + pub args: Vec, + + /// Type for the corresponding --arg (repeat; defaults to inferred types) + #[arg(long = "arg-type", value_name = "TYPE")] + pub arg_types: Vec, + + /// Network to simulate against when using --contract + #[arg(long, default_value = "testnet")] + pub network: String, + + /// Safety margin, in percent, applied to the minimum resource fee + #[arg(long, default_value_t = simulation_resources::DEFAULT_FEE_MARGIN_PERCENT)] + pub margin: u32, + + /// Per-operation inclusion (base) fee in stroops + #[arg(long, default_value_t = simulation_resources::DEFAULT_INCLUSION_FEE_STROOPS)] + pub inclusion_fee: u64, + + /// Emit the report as machine-readable JSON + #[arg(long, default_value = "false")] + pub json: bool, +} + #[derive(Args)] pub struct InvokeSimArgs { /// Contract ID to invoke @@ -288,6 +338,7 @@ pub async fn handle(cmd: SimulateCommands) -> Result<()> { SimulateCommands::RemoveFailure(args) => remove_failure(args), SimulateCommands::ToggleFailure(args) => toggle_failure(args), SimulateCommands::Status => show_status(), + SimulateCommands::Resources(args) => resource_report(args).await, SimulateCommands::Deploy(args) => deploy_contract(args), SimulateCommands::Invoke(args) => invoke_contract(args), SimulateCommands::Accounts => list_accounts(), @@ -798,6 +849,105 @@ fn show_status() -> Result<()> { Ok(()) } +// ── Resource reporting ──────────────────────────────────────────────────────── + +/// Validate the argument combination for `simulate resources`. +/// +/// Split out from the async handler so the input rules are unit-testable +/// without a network or a filesystem. +pub(crate) fn validate_resources_args(args: &ResourcesArgs) -> Result<()> { + if args.file.is_none() && args.contract.is_none() { + anyhow::bail!( + "Provide either --file to read a saved simulateTransaction \ + response, or --contract --function to simulate against a live \ + Soroban RPC endpoint." + ); + } + if args.contract.is_some() && args.function.is_none() { + anyhow::bail!("--function is required when simulating a live contract with --contract"); + } + if args.margin > simulation_resources::MAX_FEE_MARGIN_PERCENT { + anyhow::bail!( + "--margin {} is out of range (expected 0..={})", + args.margin, + simulation_resources::MAX_FEE_MARGIN_PERCENT + ); + } + if !args.arg_types.is_empty() && args.arg_types.len() != args.args.len() { + anyhow::bail!( + "--arg-type was supplied {} time(s) but --arg {} time(s); they must match one-to-one", + args.arg_types.len(), + args.args.len() + ); + } + Ok(()) +} + +async fn resource_report(args: ResourcesArgs) -> Result<()> { + validate_resources_args(&args)?; + + let resources = match (&args.file, &args.contract) { + (Some(path), _) => { + crate::utils::config::validate_file_path(path, Some("json"))?; + let raw = std::fs::read_to_string(path).map_err(|e| { + anyhow::anyhow!("Failed to read simulation response {}: {}", path.display(), e) + })?; + simulation_resources::parse_simulation_response_str(&raw) + .map_err(|e| anyhow::anyhow!("{}", e))? + } + (None, Some(contract_id)) => { + crate::utils::config::validate_contract_id(contract_id)?; + crate::utils::config::validate_network(&args.network)?; + + let function = args.function.as_deref().unwrap_or_default(); + let arg_types = if args.arg_types.is_empty() { + vec!["auto".to_string(); args.args.len()] + } else { + args.arg_types.clone() + }; + + let simulation = crate::utils::soroban::simulate_transaction( + contract_id, + function, + &args.args, + &arg_types, + &args.network, + ) + .await?; + + simulation.resources.ok_or_else(|| { + anyhow::anyhow!( + "Soroban RPC did not return resource accounting for this call{}", + if simulation.errors.is_empty() { + String::new() + } else { + format!(": {}", simulation.errors.join("; ")) + } + ) + })? + } + (None, None) => unreachable!("validated above"), + }; + + let plan = simulation_resources::plan_fee(&resources, args.margin, args.inclusion_fee) + .map_err(|e| anyhow::anyhow!("{}", e))?; + + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&simulation_resources::report_json(&resources, &plan))? + ); + } else { + simulation_resources::render_report(&resources, &plan); + p::info( + "Submit with at least the recommended fee; ledger state can move between \ + simulation and submission.", + ); + } + + Ok(()) +} + // ── Contract commands ───────────────────────────────────────────────────────── fn deploy_contract(args: DeploySimArgs) -> Result<()> { @@ -939,3 +1089,76 @@ fn reset_sim() -> Result<()> { p::success("Simulator reset to initial state"); Ok(()) } + +#[cfg(test)] +mod resource_arg_tests { + use super::*; + + fn args() -> ResourcesArgs { + ResourcesArgs { + file: None, + contract: None, + function: None, + args: Vec::new(), + arg_types: Vec::new(), + network: "testnet".to_string(), + margin: simulation_resources::DEFAULT_FEE_MARGIN_PERCENT, + inclusion_fee: simulation_resources::DEFAULT_INCLUSION_FEE_STROOPS, + json: false, + } + } + + #[test] + fn accepts_offline_file_source() { + let mut a = args(); + a.file = Some(PathBuf::from("sim.json")); + assert!(validate_resources_args(&a).is_ok()); + } + + #[test] + fn accepts_live_contract_source() { + let mut a = args(); + a.contract = Some("C".repeat(56)); + a.function = Some("balance".to_string()); + assert!(validate_resources_args(&a).is_ok()); + } + + #[test] + fn rejects_missing_source() { + let err = validate_resources_args(&args()).unwrap_err(); + assert!(err.to_string().contains("--file")); + assert!(err.to_string().contains("--contract")); + } + + #[test] + fn rejects_contract_without_function() { + let mut a = args(); + a.contract = Some("C".repeat(56)); + let err = validate_resources_args(&a).unwrap_err(); + assert!(err.to_string().contains("--function")); + } + + #[test] + fn accepts_boundary_margin_and_rejects_one_over() { + let mut a = args(); + a.file = Some(PathBuf::from("sim.json")); + a.margin = simulation_resources::MAX_FEE_MARGIN_PERCENT; + assert!(validate_resources_args(&a).is_ok()); + + a.margin = simulation_resources::MAX_FEE_MARGIN_PERCENT + 1; + assert!(validate_resources_args(&a).unwrap_err().to_string().contains("--margin")); + } + + #[test] + fn rejects_mismatched_arg_types() { + let mut a = args(); + a.contract = Some("C".repeat(56)); + a.function = Some("transfer".to_string()); + a.args = vec!["1".to_string(), "2".to_string()]; + a.arg_types = vec!["u32".to_string()]; + assert!(validate_resources_args(&a) + .unwrap_err() + .to_string() + .contains("one-to-one")); + } +} diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index ccb2c3ea..d67e14e8 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -14,7 +14,12 @@ use std::fs; use std::path::PathBuf; use stellar_strkey::ed25519::{PrivateKey as StellarPrivateKey, PublicKey as StellarPublicKey}; -const WALLET_BACKUP_VERSION: &str = "1"; +// The backup document types and their parsers live in `utils::wallet_import`, +// where they can be unit-tested, property-tested, and fuzzed without going +// through prompting or the filesystem. +use crate::utils::wallet_import::{ + self, WalletBackup, WalletBackupEntry, WALLET_BACKUP_VERSION, +}; fn kdf_options( mem: Option, @@ -39,34 +44,15 @@ fn kdf_options( Some(options) } -#[derive(Debug, Serialize, Deserialize)] -struct WalletBackup { - version: String, - exported_at: String, - wallets: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct WalletBackupEntry { - name: String, - public_key: String, - secret_key: Option, - network: String, - created_at: String, - funded: bool, -} - -impl From<&config::WalletEntry> for WalletBackupEntry { - // This was already correct, no change needed here. - fn from(entry: &config::WalletEntry) -> Self { - Self { - name: entry.name.clone(), - public_key: entry.public_key.clone(), - secret_key: entry.secret_key.clone(), - network: entry.network.clone(), - created_at: entry.created_at.clone(), - funded: entry.funded, - } +/// Build a backup entry from a stored wallet. +fn backup_entry_from(entry: &config::WalletEntry) -> WalletBackupEntry { + WalletBackupEntry { + name: entry.name.clone(), + public_key: entry.public_key.clone(), + secret_key: entry.secret_key.clone(), + network: entry.network.clone(), + created_at: entry.created_at.clone(), + funded: entry.funded, } } @@ -1179,7 +1165,7 @@ async fn rotate_wallet( let snapshot = WalletBackup { version: WALLET_BACKUP_VERSION.to_string(), exported_at: Utc::now().to_rfc3339(), - wallets: vec![WalletBackupEntry::from(&cfg.wallets[wallet_index])], + wallets: vec![backup_entry_from(&cfg.wallets[wallet_index])], }; let json = serde_json::to_string_pretty(&snapshot) .context("Failed to serialize backup snapshot")?; @@ -1370,7 +1356,7 @@ fn export_wallet(name_opt: Option, all: bool, output: PathBuf, strict: b let wallets_to_export: Vec = if all { cfg.wallets .iter() - .map(|w| WalletBackupEntry::from(w)) + .map(backup_entry_from) .collect() } else { let name = name_opt @@ -1382,7 +1368,7 @@ fn export_wallet(name_opt: Option, all: bool, output: PathBuf, strict: b .iter() .find(|w| &w.name == name) .ok_or_else(|| anyhow::anyhow!("Wallet '{}' not found", name))?; - vec![WalletBackupEntry::from(wallet)] + vec![backup_entry_from(wallet)] }; if output.exists() && output.is_dir() { @@ -1634,43 +1620,31 @@ fn import_wallets(file: PathBuf) -> Result<()> { config::validate_file_path(&file, Some("json"))?; let raw_contents = fs::read_to_string(&file).with_context(|| format!("Failed to read {}", file.display()))?; - // Detect encrypted format (salt:nonce:ciphertext) - let contents = if raw_contents.matches(':').count() == 2 { - let passphrase = crypto::prompt_password("Enter passphrase to decrypt backup", false)?; - crypto::decrypt_secret(&passphrase, &raw_contents)? - } else { - raw_contents - }; - let backup: WalletBackup = - serde_json::from_str(&contents).with_context(|| "Invalid backup JSON format")?; - if backup.version != WALLET_BACKUP_VERSION { - anyhow::bail!( - "Unsupported backup version '{}'. Expected '{}'.", - backup.version, - WALLET_BACKUP_VERSION - ); - } - - if backup.wallets.is_empty() { - anyhow::bail!("Backup file contains no wallets."); - } - - let mut seen = HashSet::new(); - for wallet in &backup.wallets { - if !seen.insert(wallet.name.clone()) { - anyhow::bail!("Duplicate wallet '{}' in backup file", wallet.name); + // Encrypted bundles have 3, 5, or 6 base64 parts depending on whether + // custom Argon2 parameters were used. The envelope is structurally checked + // before a passphrase is requested, so a corrupt file fails fast instead of + // after an Argon2 derivation. + let contents = match wallet_import::classify_payload(&raw_contents) { + wallet_import::PayloadKind::Encrypted => { + wallet_import::parse_encrypted_envelope(&raw_contents) + .map_err(|e| anyhow::anyhow!("Backup file is not a readable encrypted bundle: {}", e))?; + let passphrase = crypto::prompt_password("Enter passphrase to decrypt backup", false)?; + crypto::decrypt_secret(&passphrase, raw_contents.trim())? } + wallet_import::PayloadKind::Plaintext => raw_contents, + }; + + let parsed = wallet_import::parse_wallet_backup(&contents) + .map_err(|e| anyhow::anyhow!("Backup file rejected: {}", e))?; + for warning in &parsed.warnings { + p::warn(warning); } + let backup = parsed.backup; let mut cfg = config::load()?; for wallet in &backup.wallets { - config::validate_wallet_name(&wallet.name)?; - config::validate_public_key(&wallet.public_key)?; - if let Some(secret) = &wallet.secret_key { - config::validate_secret_key(secret)?; - } config::validate_network_exists(&cfg, &wallet.network)?; if cfg.wallets.iter().any(|w| w.name == wallet.name) { diff --git a/src/main.rs b/src/main.rs index 26a2d1ab..2f6829d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,6 +39,12 @@ struct Cli { /// Directory to write rotating log files into (optional) #[arg(long, global = true)] log_dir: Option, + + /// Correlation ID tying every log line of this invocation together. + /// Defaults to $STARFORGE_CORRELATION_ID, or a freshly generated value. + /// Must be 8–64 characters of [A-Za-z0-9_-]. + #[arg(long, global = true)] + correlation_id: Option, } #[derive(Subcommand)] @@ -308,6 +314,19 @@ async fn main() { eprintln!("Warning: failed to initialise logger: {}", e); } + // Resolve the correlation ID before any command runs so every span, retry, + // network request, plugin call, and deployment step shares it. An invalid + // explicit value is fatal: silently generating a different ID would break + // the log join the caller asked for. + let correlation_id = match utils::correlation::resolve(cli.correlation_id.as_deref()) { + Ok(id) => id, + Err(e) => { + eprintln!("Invalid correlation ID: {}", e); + std::process::exit(2); + } + }; + utils::correlation::init(correlation_id); + if !cli.quiet { print_banner(); } @@ -384,6 +403,16 @@ async fn main() { } .to_string(); + // Root span: everything below inherits `correlation_id` through the span + // stack, including work done inside spawned command handlers. + let command_span = utils::correlation::command_span(&command_name); + let _command_guard = command_span.enter(); + tracing::info!( + correlation_id = %utils::correlation::current_str(), + command = %command_name, + "command started" + ); + let start = std::time::Instant::now(); let result = match cli.command { Commands::AiDebug(cmd) => commands::ai_debug::handle(cmd).await, @@ -467,6 +496,14 @@ async fn main() { }; let duration = start.elapsed(); + tracing::info!( + correlation_id = %utils::correlation::current_str(), + command = %command_name, + success = result.is_ok(), + duration_ms = duration.as_millis() as u64, + "command finished" + ); + let _ = utils::telemetry::track_event( &command_name, serde_json::json!({ diff --git a/src/utils/config.rs b/src/utils/config.rs index a447fc58..a200dfc5 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -157,12 +157,21 @@ pub fn validate_secret_key(secret: &str) -> Result<()> { Ok(()) } -/// Validates that a network exists in the current configuration. +/// Validates that a network exists in the supplied configuration. +/// +/// Pure: it only consults `cfg` and the built-in reserved names. It must never +/// fall back to reading the on-disk configuration — validating an in-memory +/// `Config` should not depend on (or mutate) global state, or the result would +/// differ between machines and between test runs. pub fn validate_network_exists(cfg: &Config, network: &str) -> Result<()> { - if cfg.networks.contains_key(network) { + if cfg.networks.contains_key(network) || is_reserved_network(network) { return Ok(()); } - validate_network(network) + anyhow::bail!( + "Unsupported network '{}'. Use 'testnet', 'mainnet', 'docker-testnet', or a network \ + configured in this config.", + network + ) } /// Validates an amount string parses to a positive f64. @@ -220,6 +229,7 @@ pub fn validate_config(cfg: &Config) -> Result<()> { } } + let mut seen_wallets = std::collections::HashSet::new(); for wallet in &cfg.wallets { validate_wallet_name(&wallet.name)?; validate_public_key(&wallet.public_key)?; @@ -227,6 +237,12 @@ pub fn validate_config(cfg: &Config) -> Result<()> { validate_secret_key(secret)?; } validate_network_exists(cfg, &wallet.network)?; + if !seen_wallets.insert(wallet.name.as_str()) { + anyhow::bail!( + "Duplicate wallet name '{}': wallet names must be unique", + wallet.name + ); + } } for source in &cfg.plugin_trust.trusted_sources { @@ -236,6 +252,147 @@ pub fn validate_config(cfg: &Config) -> Result<()> { Ok(()) } +// ── Pure parsing / serialization / merging ─────────────────────────────────── +// +// These functions never touch the filesystem or the database. Keeping them +// pure is what makes the configuration round trip testable across generated +// inputs (see `tests/config_property_tests.rs`). + +/// Parse a configuration from a TOML document. +/// +/// Unknown keys are ignored so a config written by a newer StarForge still +/// loads; missing keys fall back to their `#[serde(default)]`. The result is +/// **not** validated — call [`validate_config`] when the values must be sane. +pub fn parse_config_str(contents: &str) -> Result { + toml::from_str(contents).context("Failed to parse configuration TOML") +} + +/// Parse a configuration from a JSON document (the format used by the local +/// database and by `starforge config export`). +pub fn parse_config_json(contents: &str) -> Result { + serde_json::from_str(contents).context("Failed to parse configuration JSON") +} + +/// Serialize a configuration to a TOML document. +/// +/// Fails if [`Config`]'s field order is ever changed so that a scalar follows a +/// table — see the note on the struct. +pub fn to_toml_string(config: &Config) -> Result { + toml::to_string_pretty(config).context("Failed to serialize configuration to TOML") +} + +/// Serialize a configuration to a JSON document. +pub fn to_json_string(config: &Config) -> Result { + serde_json::to_string_pretty(config).context("Failed to serialize configuration to JSON") +} + +/// A partial configuration layered on top of a base [`Config`]. +/// +/// Used for profile/environment overlays: `~/.config/starforge/config.toml` +/// provides the base, and an overlay (project-local file, CI environment) +/// supplies only what it wants to change. +/// +/// `deny_unknown_fields` is deliberate: an overlay is hand-written, and a typo +/// like `netwrok = "mainnet"` silently deploying to the wrong network is worse +/// than a hard parse error. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ConfigOverlay { + /// Replaces the active network. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub network: Option, + /// Replaces the telemetry opt-in flag. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub telemetry_enabled: Option, + /// Replaces the wallet encryption KDF settings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub wallet_encryption: Option, + /// Replaces the feature-flag settings wholesale. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub feature_flags: Option, + /// Replaces the AI telemetry settings wholesale. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ai_telemetry: Option, + /// Replaces the plugin trust allowlist wholesale. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_trust: Option, + /// Networks to add, or to replace by name. + #[serde(default)] + pub networks: HashMap, + /// Wallets to append. A name that already exists in the base is an error + /// rather than a silent overwrite — wallets hold key material. + #[serde(default)] + pub wallets: Vec, +} + +impl ConfigOverlay { + /// True when the overlay would not change anything. + pub fn is_empty(&self) -> bool { + self == &ConfigOverlay::default() + } +} + +/// Parse a [`ConfigOverlay`] from TOML, rejecting unknown keys. +pub fn parse_overlay_str(contents: &str) -> Result { + toml::from_str(contents).context("Failed to parse configuration overlay TOML") +} + +/// Layer `overlay` on top of `base` and validate the result. +/// +/// Precedence rules: +/// - scalars (`network`, `telemetry_enabled`, `wallet_encryption`) — overlay +/// wins when it sets them, base is kept otherwise; +/// - `feature_flags` / `plugin_trust` / `ai_telemetry` — replaced wholesale when +/// present, so a partially-specified table can never produce a half-merged +/// policy; +/// - `networks` — merged by key; an overlay entry replaces the base entry of +/// the same name; +/// - `wallets` — appended; a duplicate name is rejected; +/// - `version` and `install_id` — always taken from the base. They identify the +/// installation and its schema, and an overlay must not forge either. +/// +/// The merged config is validated before it is returned, so a merge can never +/// produce a config that [`save`] would reject. +pub fn merge_configs(base: Config, overlay: ConfigOverlay) -> Result { + let mut merged = base; + + for wallet in &overlay.wallets { + if merged.wallets.iter().any(|w| w.name == wallet.name) { + anyhow::bail!( + "Overlay wallet '{}' already exists in the base configuration; \ + rename it or remove it from the overlay", + wallet.name + ); + } + } + + if let Some(network) = overlay.network { + merged.network = network; + } + if let Some(telemetry) = overlay.telemetry_enabled { + merged.telemetry_enabled = Some(telemetry); + } + if let Some(kdf) = overlay.wallet_encryption { + merged.wallet_encryption = Some(kdf); + } + if let Some(flags) = overlay.feature_flags { + merged.feature_flags = flags; + } + if let Some(ai_telemetry) = overlay.ai_telemetry { + merged.ai_telemetry = ai_telemetry; + } + if let Some(trust) = overlay.plugin_trust { + merged.plugin_trust = trust; + } + for (name, net) in overlay.networks { + merged.networks.insert(name, net); + } + merged.wallets.extend(overlay.wallets); + + validate_config(&merged)?; + Ok(merged) +} + fn validate_endpoint_url(url: &str, label: &str) -> Result<()> { if url.starts_with("http://") || url.starts_with("https://") { Ok(()) @@ -248,28 +405,35 @@ fn validate_endpoint_url(url: &str, label: &str) -> Result<()> { } } -#[derive(Debug, Serialize, Deserialize, Clone)] +/// The persisted StarForge configuration. +/// +/// **Field order matters.** TOML requires every scalar value of a table to be +/// emitted before any sub-table, so all scalars are declared first, then +/// tables, then arrays of tables. Reordering scalars below a table makes +/// [`to_toml_string`] fail at runtime. Deserialization is by key, so the order +/// is free to change without breaking existing config files. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct Config { #[serde(default = "default_version")] pub version: String, pub network: String, - pub wallets: Vec, - #[serde(default)] - pub networks: std::collections::HashMap, - #[serde(default)] - pub plugin_trust: PluginTrustConfig, pub telemetry_enabled: Option, - pub wallet_encryption: Option, /// Optional per-install UUIDv4. Lazily created on first load. /// Stable identifier used for deterministic feature-flag bucketing. #[serde(default)] pub install_id: Option, + pub wallet_encryption: Option, + #[serde(default)] + pub networks: std::collections::HashMap, + #[serde(default)] + pub plugin_trust: PluginTrustConfig, /// Feature flag system configuration. #[serde(default)] pub feature_flags: FeatureFlagsConfig, /// AI telemetry (usage analytics) configuration. #[serde(default)] pub ai_telemetry: AiTelemetryConfig, + pub wallets: Vec, } /// Local knobs for the AI usage-telemetry system (issue #482). @@ -353,7 +517,7 @@ fn default_version() -> String { "1".to_string() } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct NetworkConfig { pub horizon_url: String, pub soroban_rpc_url: Option, @@ -482,7 +646,7 @@ pub fn reset_trusted_plugin_sources(config: &mut Config) { config.plugin_trust.trusted_sources = default_trusted_plugin_sources(); } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct WalletEntry { pub name: String, pub public_key: String, @@ -494,7 +658,7 @@ pub struct WalletEntry { pub rotation_history: Vec, } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct WalletRotationRecord { pub rotated_at: String, pub previous_public_key: String, diff --git a/src/utils/correlation.rs b/src/utils/correlation.rs new file mode 100644 index 00000000..0189889c --- /dev/null +++ b/src/utils/correlation.rs @@ -0,0 +1,554 @@ +//! Correlation IDs for structured command logs. +//! +//! Every `starforge` invocation gets exactly one correlation ID. It is attached +//! to the root command span, so every event emitted underneath it — retries, +//! network requests, plugin calls, deployment steps — carries the same ID and a +//! single invocation can be reconstructed from an aggregated log stream. +//! +//! ```text +//! {"timestamp":"…","level":"INFO","fields":{"message":"attempt failed"}, +//! "span":{"attempt":2,"kind":"retry"}, +//! "spans":[{"command":"deploy","correlation_id":"01J8Z…","kind":"command"}]} +//! ``` +//! +//! ## Security +//! +//! A correlation ID ends up in every log line and is frequently forwarded to +//! log aggregators, so it must never carry anything sensitive: +//! +//! - generated IDs are random (UUIDv4, hyphens stripped) and derived from +//! nothing about the user or their keys; +//! - a supplied ID is restricted to `[A-Za-z0-9_-]` and 8–64 characters; +//! - a supplied ID that looks like key material (a Stellar `S…` secret, an +//! encrypted bundle, a long base64 blob) is rejected outright rather than +//! logged; +//! - span helpers run their attributes through [`sanitize`], and URLs through +//! [`redact_url`], so credentials in a query string or in userinfo never +//! reach the log. + +use once_cell::sync::OnceCell; +use tracing::Span; + +/// Environment variable that supplies a correlation ID (e.g. a CI job ID), so +/// StarForge logs can be joined against the surrounding pipeline's logs. +pub const ENV_CORRELATION_ID: &str = "STARFORGE_CORRELATION_ID"; + +/// Shortest correlation ID accepted. Anything shorter collides too easily to +/// be useful for joining log lines. +pub const MIN_CORRELATION_ID_LEN: usize = 8; + +/// Longest correlation ID accepted. Keeps log lines bounded. +pub const MAX_CORRELATION_ID_LEN: usize = 64; + +/// Maximum length of a sanitized span attribute value. +pub const MAX_ATTRIBUTE_LEN: usize = 120; + +/// The placeholder written in place of anything that looks sensitive. +pub const REDACTED: &str = "[REDACTED]"; + +static CURRENT: OnceCell = OnceCell::new(); + +// ───────────────────────────────────────────────────────────────────────────── +// Correlation ID +// ───────────────────────────────────────────────────────────────────────────── + +/// A validated correlation ID. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct CorrelationId(String); + +/// Why a supplied correlation ID was refused. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CorrelationIdError { + /// The value was empty or only whitespace. + Empty, + /// Shorter than [`MIN_CORRELATION_ID_LEN`]. + TooShort(usize), + /// Longer than [`MAX_CORRELATION_ID_LEN`]. + TooLong(usize), + /// Contained a character outside `[A-Za-z0-9_-]`. + InvalidCharacter(char), + /// The value resembles key material and must not be logged. + LooksLikeSecret, +} + +impl std::fmt::Display for CorrelationIdError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Empty => write!(f, "correlation ID cannot be empty"), + Self::TooShort(len) => write!( + f, + "correlation ID is {} characters; at least {} are required", + len, MIN_CORRELATION_ID_LEN + ), + Self::TooLong(len) => write!( + f, + "correlation ID is {} characters; at most {} are allowed", + len, MAX_CORRELATION_ID_LEN + ), + Self::InvalidCharacter(ch) => write!( + f, + "correlation ID contains '{}'; only letters, digits, '-' and '_' are allowed", + ch + ), + Self::LooksLikeSecret => write!( + f, + "correlation ID looks like key material and would be written to every log line; \ + use an opaque identifier instead" + ), + } + } +} + +impl std::error::Error for CorrelationIdError {} + +impl CorrelationId { + /// Generate a fresh random correlation ID. + pub fn generate() -> Self { + Self(uuid::Uuid::new_v4().simple().to_string()) + } + + /// Validate an externally supplied correlation ID. + pub fn parse(raw: &str) -> Result { + let value = raw.trim(); + + if value.is_empty() { + return Err(CorrelationIdError::Empty); + } + if let Some(bad) = value + .chars() + .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_')) + { + return Err(CorrelationIdError::InvalidCharacter(bad)); + } + // Length is measured after the charset check so a multi-byte character + // is reported as an invalid character rather than as a length problem. + if value.len() < MIN_CORRELATION_ID_LEN { + return Err(CorrelationIdError::TooShort(value.len())); + } + if value.len() > MAX_CORRELATION_ID_LEN { + return Err(CorrelationIdError::TooLong(value.len())); + } + if looks_like_secret(value) { + return Err(CorrelationIdError::LooksLikeSecret); + } + + Ok(Self(value.to_string())) + } + + /// The validated value. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for CorrelationId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Resolve the correlation ID for this invocation. +/// +/// Precedence: an explicit `--correlation-id` flag, then +/// [`ENV_CORRELATION_ID`], then a freshly generated one. An explicitly +/// supplied value that fails validation is an error — silently replacing it +/// would break the join the caller asked for. +pub fn resolve(explicit: Option<&str>) -> Result { + if let Some(value) = explicit { + return CorrelationId::parse(value); + } + match std::env::var(ENV_CORRELATION_ID) { + Ok(value) if !value.trim().is_empty() => CorrelationId::parse(&value), + _ => Ok(CorrelationId::generate()), + } +} + +/// Install the correlation ID for this process. +/// +/// The first call wins; later calls return the already-installed ID so a +/// re-entrant command (the REPL, a plugin host) cannot fragment a single +/// invocation across several IDs. +pub fn init(id: CorrelationId) -> &'static CorrelationId { + CURRENT.get_or_init(|| id) +} + +/// The correlation ID installed by [`init`], if any. +pub fn current() -> Option<&'static CorrelationId> { + CURRENT.get() +} + +/// The correlation ID as a string, or `"unset"` before [`init`] runs. +/// +/// Used by the span helpers so a stray log line from a code path that ran +/// before initialisation is still well-formed. +pub fn current_str() -> &'static str { + CURRENT.get().map(|id| id.as_str()).unwrap_or("unset") +} + +// ───────────────────────────────────────────────────────────────────────────── +// Redaction +// ───────────────────────────────────────────────────────────────────────────── + +/// True when `value` resembles key material that must never be logged. +pub fn looks_like_secret(value: &str) -> bool { + // A Stellar secret seed: 'S' + 55 base32 characters. + if value.len() == 56 + && value.starts_with('S') + && value.chars().all(|c| matches!(c, 'A'..='Z' | '2'..='7')) + { + return true; + } + // An encrypted bundle: salt:nonce:ciphertext(:kdf params). Matched + // precisely rather than on "contains a colon", so ordinary values with + // colons (URLs, `host:port`, timestamps) are not needlessly redacted. + if !value.contains("//") { + let parts: Vec<&str> = value.split(':').collect(); + if matches!(parts.len(), 3 | 5 | 6) + && parts.iter().take(3).all(|part| { + part.len() >= 8 + && part + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=') + }) + { + return true; + } + } + // A long opaque base64 blob — plausibly ciphertext or a bearer token. + if value.len() >= 40 + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=') + && value.chars().any(|c| c.is_ascii_lowercase()) + && value.chars().any(|c| c.is_ascii_uppercase()) + && value.chars().any(|c| c.is_ascii_digit()) + { + return true; + } + false +} + +/// Prepare an arbitrary value for use as a span attribute. +/// +/// Redacts anything that looks like key material, strips control characters +/// (which could forge log records), and truncates to [`MAX_ATTRIBUTE_LEN`]. +pub fn sanitize(value: &str) -> String { + if looks_like_secret(value) { + return REDACTED.to_string(); + } + + let cleaned: String = value + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + + if cleaned.chars().count() <= MAX_ATTRIBUTE_LEN { + return cleaned; + } + let truncated: String = cleaned.chars().take(MAX_ATTRIBUTE_LEN).collect(); + format!("{}…", truncated) +} + +/// Reduce a URL to scheme, host, and path. +/// +/// Query strings and userinfo routinely carry API keys, so they are dropped +/// rather than sanitized — the host and path are all a log reader needs to +/// correlate a request. +pub fn redact_url(url: &str) -> String { + let (scheme, rest) = match url.split_once("://") { + Some((scheme, rest)) => (scheme, rest), + // Not a URL we understand; treat it as a plain attribute. + None => return sanitize(url), + }; + + let rest = rest.split(['?', '#']).next().unwrap_or(""); + let (authority, path) = match rest.split_once('/') { + Some((authority, path)) => (authority, format!("/{}", path)), + None => (rest, String::new()), + }; + + // Drop any `user:password@` prefix. + let host = authority.rsplit('@').next().unwrap_or(authority); + + // Sanitize the components individually: reassembling first and sanitizing + // the whole string would let the `host:port` colons trip the bundle + // heuristic. + let rebuilt = format!( + "{}://{}{}", + sanitize(scheme), + sanitize(host), + sanitize(&path) + ); + if rebuilt.chars().count() > MAX_ATTRIBUTE_LEN { + return sanitize(&rebuilt); + } + rebuilt +} + +// ───────────────────────────────────────────────────────────────────────────── +// Spans +// ───────────────────────────────────────────────────────────────────────────── + +/// The root span for a command invocation. +/// +/// Enter this once in `main`; every span and event created afterwards inherits +/// the correlation ID through the span stack. +pub fn command_span(command: &str) -> Span { + tracing::info_span!( + "command", + kind = "command", + command = %sanitize(command), + correlation_id = %current_str(), + ) +} + +/// A span for one attempt of a retried operation. +pub fn retry_span(operation: &str, attempt: u32, max_attempts: u32) -> Span { + tracing::debug_span!( + "retry", + kind = "retry", + operation = %sanitize(operation), + attempt = attempt, + max_attempts = max_attempts, + correlation_id = %current_str(), + ) +} + +/// A span for an outbound network request. The URL is reduced to scheme, host, +/// and path — never the query string. +pub fn network_span(method: &str, url: &str) -> Span { + tracing::debug_span!( + "network_request", + kind = "network_request", + method = %sanitize(method), + url = %redact_url(url), + correlation_id = %current_str(), + ) +} + +/// A span for a call into an external plugin. +pub fn plugin_span(plugin: &str, entrypoint: &str) -> Span { + tracing::debug_span!( + "plugin_call", + kind = "plugin_call", + plugin = %sanitize(plugin), + entrypoint = %sanitize(entrypoint), + correlation_id = %current_str(), + ) +} + +/// A span for one step of a multi-step deployment. +pub fn deploy_step_span(step: &str, index: usize, total: usize) -> Span { + tracing::info_span!( + "deploy_step", + kind = "deploy_step", + step = %sanitize(step), + index = index, + total = total, + correlation_id = %current_str(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Primary flow ──────────────────────────────────────────────────────── + + #[test] + fn generated_ids_are_valid_and_unique() { + let a = CorrelationId::generate(); + let b = CorrelationId::generate(); + + assert_ne!(a, b); + assert_eq!(a.as_str().len(), 32); + assert!(CorrelationId::parse(a.as_str()).is_ok()); + assert!(a.as_str().chars().all(|c| c.is_ascii_alphanumeric())); + } + + #[test] + fn explicit_id_is_used_verbatim() { + let id = resolve(Some("ci-job-4711")).unwrap(); + assert_eq!(id.as_str(), "ci-job-4711"); + } + + #[test] + fn explicit_id_is_trimmed() { + assert_eq!( + resolve(Some(" ci-job-4711 ")).unwrap().as_str(), + "ci-job-4711" + ); + } + + #[test] + fn resolve_without_input_generates_an_id() { + // Not reading the environment here: `resolve(None)` is exercised for the + // env path in `env_var_is_used_when_no_flag_is_given`. + let id = CorrelationId::generate(); + assert!(CorrelationId::parse(id.as_str()).is_ok()); + } + + #[test] + fn env_var_is_used_when_no_flag_is_given() { + // Serialised implicitly: this is the only test touching the variable. + std::env::set_var(ENV_CORRELATION_ID, "pipeline-2026-07-29"); + let id = resolve(None).unwrap(); + std::env::remove_var(ENV_CORRELATION_ID); + + assert_eq!(id.as_str(), "pipeline-2026-07-29"); + } + + #[test] + fn init_is_idempotent_and_first_call_wins() { + let first = CorrelationId::parse("first-invocation").unwrap(); + let installed = init(first.clone()).clone(); + let second = init(CorrelationId::parse("second-invocation").unwrap()).clone(); + + assert_eq!(installed, second); + assert_eq!(current().unwrap(), &installed); + assert_eq!(current_str(), installed.as_str()); + } + + #[test] + fn spans_carry_the_correlation_id() { + // The span helpers must not panic and must record a correlation id + // field even before `init` has run. + let spans = [ + command_span("deploy"), + retry_span("horizon-submit", 2, 5), + network_span("POST", "https://soroban-testnet.stellar.org"), + plugin_span("starforge-lint", "run"), + deploy_step_span("upload-wasm", 1, 4), + ]; + for span in spans { + let _entered = span.enter(); + } + } + + // ── Boundary cases ────────────────────────────────────────────────────── + + #[test] + fn shortest_and_longest_ids_are_accepted() { + let shortest = "a".repeat(MIN_CORRELATION_ID_LEN); + let longest = "a".repeat(MAX_CORRELATION_ID_LEN); + + assert!(CorrelationId::parse(&shortest).is_ok()); + assert!(CorrelationId::parse(&longest).is_ok()); + } + + #[test] + fn one_character_outside_the_range_is_rejected() { + let too_short = "a".repeat(MIN_CORRELATION_ID_LEN - 1); + let too_long = "a".repeat(MAX_CORRELATION_ID_LEN + 1); + + assert_eq!( + CorrelationId::parse(&too_short).unwrap_err(), + CorrelationIdError::TooShort(MIN_CORRELATION_ID_LEN - 1) + ); + assert_eq!( + CorrelationId::parse(&too_long).unwrap_err(), + CorrelationIdError::TooLong(MAX_CORRELATION_ID_LEN + 1) + ); + } + + #[test] + fn attribute_at_the_length_limit_is_not_truncated() { + let exact = "x".repeat(MAX_ATTRIBUTE_LEN); + assert_eq!(sanitize(&exact), exact); + + let over = "x".repeat(MAX_ATTRIBUTE_LEN + 1); + let sanitized = sanitize(&over); + assert!(sanitized.ends_with('…')); + assert_eq!(sanitized.chars().count(), MAX_ATTRIBUTE_LEN + 1); + } + + // ── Failure cases ─────────────────────────────────────────────────────── + + #[test] + fn empty_and_whitespace_ids_are_rejected() { + assert_eq!( + CorrelationId::parse("").unwrap_err(), + CorrelationIdError::Empty + ); + assert_eq!( + CorrelationId::parse(" ").unwrap_err(), + CorrelationIdError::Empty + ); + } + + #[test] + fn ids_with_disallowed_characters_are_rejected() { + for bad in [ + "has space", + "semi;colon", + "new\nline", + "emoji-🚀-id", + "sla/sh", + ] { + assert!( + matches!( + CorrelationId::parse(bad), + Err(CorrelationIdError::InvalidCharacter(_)) + ), + "accepted {:?}", + bad + ); + } + } + + #[test] + fn a_stellar_secret_key_is_never_accepted_as_an_id() { + let secret = format!("S{}", "A".repeat(55)); + assert_eq!( + CorrelationId::parse(&secret).unwrap_err(), + CorrelationIdError::LooksLikeSecret + ); + } + + #[test] + fn an_invalid_env_var_is_an_error_not_a_silent_fallback() { + std::env::set_var(ENV_CORRELATION_ID, "no"); + let result = resolve(None); + std::env::remove_var(ENV_CORRELATION_ID); + + assert!(result.is_err(), "short env value should not be accepted"); + } + + #[test] + fn secrets_in_attributes_are_redacted() { + let secret = format!("S{}", "A".repeat(55)); + assert_eq!(sanitize(&secret), REDACTED); + assert_eq!(sanitize("c2FsdA==:bm9uY2U=:Y2lwaGVy"), REDACTED); + } + + #[test] + fn control_characters_cannot_forge_log_records() { + let forged = "deploy\n{\"level\":\"ERROR\"}"; + let sanitized = sanitize(forged); + assert!(!sanitized.contains('\n')); + assert!(sanitized.starts_with("deploy ")); + } + + #[test] + fn urls_lose_their_credentials_and_query_strings() { + assert_eq!( + redact_url("https://user:hunter2@rpc.example.com/soroban?apiKey=abcdef123456"), + "https://rpc.example.com/soroban" + ); + assert_eq!( + redact_url("https://soroban-testnet.stellar.org"), + "https://soroban-testnet.stellar.org" + ); + assert_eq!( + redact_url("http://localhost:8000/rpc#fragment"), + "http://localhost:8000/rpc" + ); + } + + #[test] + fn a_non_url_attribute_is_still_sanitized() { + assert_eq!(redact_url("not a url"), "not a url"); + let secret = format!("S{}", "A".repeat(55)); + assert_eq!(redact_url(&secret), REDACTED); + } +} diff --git a/src/utils/logging.rs b/src/utils/logging.rs index dff9d6a5..6290cf59 100644 --- a/src/utils/logging.rs +++ b/src/utils/logging.rs @@ -58,11 +58,15 @@ pub fn init(config: LogConfig) -> Result<()> { let file_layer = fmt::layer() .json() + .with_current_span(true) + .with_span_list(true) .with_writer(non_blocking) .with_filter(env_filter.clone()); let stderr_layer = fmt::layer() .json() + .with_current_span(true) + .with_span_list(true) .with_writer(std::io::stderr) .with_filter(env_filter); @@ -77,6 +81,8 @@ pub fn init(config: LogConfig) -> Result<()> { (LogFormat::Json, None) => { let layer = fmt::layer() .json() + .with_current_span(true) + .with_span_list(true) .with_writer(std::io::stderr) .with_filter(env_filter); diff --git a/src/utils/mod.rs b/src/utils/mod.rs index d3e432ae..4ddeb0a8 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -33,6 +33,7 @@ pub mod contract_profiler; pub mod contract_test_framework; pub mod contract_test_runner; pub mod contract_testing; +pub mod correlation; pub mod cost_estimation; pub mod cost_management; pub mod crypto; @@ -85,6 +86,7 @@ pub mod sandbox; pub mod scheduler; pub mod security; pub mod security_scanner; +pub mod simulation_resources; pub mod social; pub mod soroban; pub mod state_diff; @@ -107,6 +109,7 @@ pub mod test_runner; pub mod testnet_integration; pub mod tutorial_engine; pub mod tx_batch; +pub mod wallet_import; pub mod wallet_signer; pub mod wasm_hash; pub mod workflow_guidance; diff --git a/src/utils/simulation_resources.rs b/src/utils/simulation_resources.rs new file mode 100644 index 00000000..1db4fbcc --- /dev/null +++ b/src/utils/simulation_resources.rs @@ -0,0 +1,964 @@ +//! Soroban simulation resource accounting and transaction fee planning. +//! +//! `simulateTransaction` is the only source of truth for what a Soroban +//! transaction will actually cost: the RPC server runs the invocation against a +//! real ledger snapshot and reports the CPU instructions burned, the linear +//! memory touched, the ledger footprint that must be declared, and the +//! **minimum resource fee** the transaction has to carry to be accepted. +//! +//! This module turns a raw `simulateTransaction` JSON-RPC response into a +//! strongly-typed [`SimulationResources`] value and derives a +//! [`ResourceFeePlan`] from it, so `simulate`, `cost`, and `deploy` all report +//! the same numbers instead of guessing at a hard-coded fee. +//! +//! ## Compatibility +//! +//! | RPC field | Protocol 20 | Protocol 21/22 | Notes | +//! |--------------------|-------------|----------------|-------| +//! | `minResourceFee` | yes | yes | required; parse fails without it | +//! | `cost.cpuInsns` | yes | deprecated | falls back to `transactionData` | +//! | `cost.memBytes` | yes | deprecated | reported as "unavailable" when absent | +//! | `transactionData` | yes | yes | source of the footprint | +//! | `restorePreamble` | no | yes | archived entries need a restore tx first | +//! +//! Every numeric field is accepted either as a JSON number or as a JSON string +//! (stellar-rpc emits `u64` values as strings), and anything that is not a +//! non-negative integer is rejected rather than silently coerced to zero. + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use stellar_xdr::curr::{Limits, ReadXdr, SorobanTransactionData}; + +/// Stroops in one XLM. +pub const STROOPS_PER_XLM: f64 = 10_000_000.0; + +/// Default safety margin applied on top of the simulated minimum resource fee. +/// +/// Ledger state can move between simulation and submission, so submitting +/// exactly `minResourceFee` is a coin flip. 20% is the margin the Stellar CLI +/// uses by default. +pub const DEFAULT_FEE_MARGIN_PERCENT: u32 = 20; + +/// Upper bound accepted for `--margin`. Anything higher is almost certainly a +/// typo (e.g. passing stroops instead of a percentage). +pub const MAX_FEE_MARGIN_PERCENT: u32 = 1_000; + +/// Default per-operation inclusion fee in stroops (the network base fee). +pub const DEFAULT_INCLUSION_FEE_STROOPS: u64 = 100; + +/// Largest simulation response we are willing to parse, in bytes. Guards the +/// `--file` code path against accidentally reading a multi-gigabyte artifact. +pub const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024; + +// ───────────────────────────────────────────────────────────────────────────── +// Errors +// ───────────────────────────────────────────────────────────────────────────── + +/// Everything that can go wrong while turning a simulation response into a +/// resource plan. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SimulationResourceError { + /// The payload was not a JSON object (e.g. an array, a bare string, null). + NotAnObject, + /// The JSON-RPC envelope carried an `error` member. + RpcError(String), + /// The simulation itself failed on the host (contract panic, bad auth, …). + SimulationFailed(String), + /// A field was present but not usable. + InvalidField { field: String, reason: String }, + /// A field required for fee planning was absent. + MissingField(String), + /// The response is missing the resource accounting this command needs, + /// typically because the endpoint is not a Soroban RPC server. + UnsupportedResponse(String), + /// The requested margin is outside the accepted range. + InvalidMargin(u32), + /// Fee arithmetic overflowed `u64`. + FeeOverflow, + /// Input exceeded [`MAX_RESPONSE_BYTES`]. + ResponseTooLarge { bytes: usize, limit: usize }, +} + +impl std::fmt::Display for SimulationResourceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotAnObject => write!( + f, + "simulation response is not a JSON object; expected the result of a \ + `simulateTransaction` call" + ), + Self::RpcError(msg) => write!(f, "Soroban RPC returned an error: {}", msg), + Self::SimulationFailed(msg) => write!(f, "simulation failed on the host: {}", msg), + Self::InvalidField { field, reason } => { + write!(f, "invalid `{}` in simulation response: {}", field, reason) + } + Self::MissingField(field) => write!( + f, + "simulation response is missing `{}`; cannot plan transaction resources", + field + ), + Self::UnsupportedResponse(msg) => write!(f, "unsupported simulation response: {}", msg), + Self::InvalidMargin(pct) => write!( + f, + "fee margin {}% is out of range (expected 0..={})", + pct, MAX_FEE_MARGIN_PERCENT + ), + Self::FeeOverflow => write!(f, "resource fee calculation overflowed u64 stroops"), + Self::ResponseTooLarge { bytes, limit } => write!( + f, + "simulation response is {} bytes, above the {} byte limit", + bytes, limit + ), + } + } +} + +impl std::error::Error for SimulationResourceError {} + +type Result = std::result::Result; + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +/// The ledger footprint a transaction must declare, decoded from the +/// `transactionData` XDR returned by simulation. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SimulationFootprint { + /// Number of read-only ledger keys. + pub read_only_entries: usize, + /// Number of read-write ledger keys. + pub read_write_entries: usize, + /// Bytes read from the ledger. + pub read_bytes: u32, + /// Bytes written back to the ledger. + pub write_bytes: u32, + /// CPU instructions declared in the resource section of `transactionData`. + pub instructions: u32, + /// Resource fee (stroops) baked into `transactionData` by the simulator. + pub resource_fee_stroops: i64, +} + +impl SimulationFootprint { + /// Total number of ledger entries touched. + pub fn total_entries(&self) -> usize { + self.read_only_entries + self.read_write_entries + } +} + +/// Resource accounting extracted from a `simulateTransaction` response. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SimulationResources { + /// Minimum resource fee in stroops the transaction must carry. + pub min_resource_fee_stroops: u64, + /// CPU instructions consumed. `None` when the RPC server no longer reports + /// the deprecated `cost` object and `transactionData` was unavailable. + pub cpu_instructions: Option, + /// Linear memory high-water mark in bytes. `None` on RPC servers that have + /// dropped the deprecated `cost` object. + pub memory_bytes: Option, + /// Declared ledger footprint, when `transactionData` was present. + pub footprint: Option, + /// Ledger the simulation ran against. + pub latest_ledger: Option, + /// Extra resource fee for the restore transaction that must run first when + /// the footprint touches archived entries. + pub restore_fee_stroops: Option, + /// Non-fatal notes about fields the server did not provide. + pub warnings: Vec, +} + +impl SimulationResources { + /// True when the RPC response told us archived ledger entries must be + /// restored before this transaction can succeed. + pub fn requires_restore(&self) -> bool { + self.restore_fee_stroops.is_some() + } + + /// Minimum resource fee expressed in XLM. + pub fn min_resource_fee_xlm(&self) -> f64 { + self.min_resource_fee_stroops as f64 / STROOPS_PER_XLM + } +} + +/// A submittable fee derived from simulation output. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResourceFeePlan { + /// Minimum resource fee reported by simulation. + pub min_resource_fee_stroops: u64, + /// Resource fee for the prerequisite restore transaction, if any. + pub restore_fee_stroops: u64, + /// Per-operation inclusion (base) fee. + pub inclusion_fee_stroops: u64, + /// Safety margin applied to the resource fees, as a percentage. + pub margin_percent: u32, + /// Absolute stroops the margin adds. + pub margin_stroops: u64, + /// Fee to put on the transaction: resource + restore + margin + inclusion. + pub recommended_fee_stroops: u64, +} + +impl ResourceFeePlan { + /// Recommended fee expressed in XLM. + pub fn recommended_fee_xlm(&self) -> f64 { + self.recommended_fee_stroops as f64 / STROOPS_PER_XLM + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Parsing +// ───────────────────────────────────────────────────────────────────────────── + +/// Parse a raw JSON document (either a full JSON-RPC envelope or a bare +/// `result` object) into [`SimulationResources`]. +/// +/// Rejects inputs above [`MAX_RESPONSE_BYTES`] before touching the JSON parser. +pub fn parse_simulation_response_str(raw: &str) -> Result { + if raw.len() > MAX_RESPONSE_BYTES { + return Err(SimulationResourceError::ResponseTooLarge { + bytes: raw.len(), + limit: MAX_RESPONSE_BYTES, + }); + } + let value: Value = + serde_json::from_str(raw).map_err(|e| SimulationResourceError::InvalidField { + field: "".to_string(), + reason: format!("not valid JSON: {}", e), + })?; + parse_simulation_resources(&value) +} + +/// Parse a `simulateTransaction` response into [`SimulationResources`]. +/// +/// Accepts both the full JSON-RPC envelope (`{"jsonrpc":…,"result":{…}}`) and a +/// bare result object, so callers can feed it either a saved response or the +/// already-unwrapped value from [`crate::utils::soroban`]. +pub fn parse_simulation_resources(value: &Value) -> Result { + let obj = value + .as_object() + .ok_or(SimulationResourceError::NotAnObject)?; + + // JSON-RPC transport error takes precedence over anything else. + if let Some(err) = obj.get("error") { + // A bare `result` object also uses `error` for host failures; the + // transport variant is an object with `code`/`message`. + if err.is_object() { + let msg = err + .get("message") + .and_then(Value::as_str) + .unwrap_or("unknown error"); + return Err(SimulationResourceError::RpcError(msg.to_string())); + } + if let Some(msg) = err.as_str() { + if !msg.is_empty() { + return Err(SimulationResourceError::SimulationFailed(msg.to_string())); + } + } + } + + // Unwrap the JSON-RPC envelope when present. + if let Some(result) = obj.get("result") { + if result.is_object() { + return parse_simulation_resources(result); + } + } + + if !obj.contains_key("minResourceFee") && !obj.contains_key("transactionData") { + return Err(SimulationResourceError::UnsupportedResponse( + "no `minResourceFee` or `transactionData`; the endpoint does not look like a \ + Soroban RPC `simulateTransaction` result" + .to_string(), + )); + } + + let mut warnings = Vec::new(); + + let min_resource_fee_stroops = match obj.get("minResourceFee") { + Some(v) => parse_u64_field(v, "minResourceFee")?, + None => { + return Err(SimulationResourceError::MissingField( + "minResourceFee".to_string(), + )) + } + }; + + let footprint = match obj.get("transactionData") { + Some(Value::Null) | None => { + warnings.push( + "`transactionData` absent: footprint and declared resources are unavailable" + .to_string(), + ); + None + } + Some(v) => { + let encoded = v + .as_str() + .ok_or_else(|| SimulationResourceError::InvalidField { + field: "transactionData".to_string(), + reason: "expected a base64-encoded XDR string".to_string(), + })?; + if encoded.is_empty() { + warnings.push("`transactionData` is empty: footprint unavailable".to_string()); + None + } else { + Some(decode_footprint(encoded)?) + } + } + }; + + let cost = obj.get("cost"); + let mut cpu_instructions = match cost.and_then(|c| c.get("cpuInsns")) { + Some(v) => Some(parse_u64_field(v, "cost.cpuInsns")?), + None => None, + }; + let memory_bytes = match cost.and_then(|c| c.get("memBytes")) { + Some(v) => Some(parse_u64_field(v, "cost.memBytes")?), + None => { + warnings.push( + "`cost.memBytes` not reported by this RPC server (deprecated since protocol 21)" + .to_string(), + ); + None + } + }; + + // Protocol 21+ servers drop `cost`; the declared instruction count in + // `transactionData` is the remaining CPU signal. + if cpu_instructions.is_none() { + if let Some(fp) = footprint.as_ref() { + cpu_instructions = Some(u64::from(fp.instructions)); + warnings.push( + "CPU instructions taken from `transactionData` because `cost.cpuInsns` is absent" + .to_string(), + ); + } + } + + let latest_ledger = match obj.get("latestLedger") { + Some(v) => Some( + u32::try_from(parse_u64_field(v, "latestLedger")?).map_err(|_| { + SimulationResourceError::InvalidField { + field: "latestLedger".to_string(), + reason: "value does not fit in a u32 ledger sequence".to_string(), + } + })?, + ), + None => None, + }; + + let restore_fee_stroops = match obj.get("restorePreamble") { + Some(Value::Null) | None => None, + Some(preamble) => { + let fee = preamble.get("minResourceFee").ok_or_else(|| { + SimulationResourceError::MissingField("restorePreamble.minResourceFee".to_string()) + })?; + Some(parse_u64_field(fee, "restorePreamble.minResourceFee")?) + } + }; + if restore_fee_stroops.is_some() { + warnings.push( + "footprint touches archived entries: a restore transaction must be submitted first" + .to_string(), + ); + } + + Ok(SimulationResources { + min_resource_fee_stroops, + cpu_instructions, + memory_bytes, + footprint, + latest_ledger, + restore_fee_stroops, + warnings, + }) +} + +/// Accept a `u64` supplied either as a JSON number or a JSON string. +/// +/// stellar-rpc serialises 64-bit counters as strings to survive JavaScript +/// clients; both spellings must round-trip, but floats, negatives, and +/// non-numeric text are hard errors rather than silent zeroes. +fn parse_u64_field(value: &Value, field: &str) -> Result { + let invalid = |reason: &str| SimulationResourceError::InvalidField { + field: field.to_string(), + reason: reason.to_string(), + }; + + match value { + Value::Number(n) => { + if let Some(v) = n.as_u64() { + Ok(v) + } else if n.as_i64().is_some() { + Err(invalid("expected a non-negative integer")) + } else { + Err(invalid("expected an integer, got a fractional number")) + } + } + Value::String(s) => { + let trimmed = s.trim(); + if trimmed.is_empty() { + return Err(invalid("value is empty")); + } + if !trimmed.bytes().all(|b| b.is_ascii_digit()) { + return Err(invalid("expected a decimal non-negative integer string")); + } + trimmed + .parse::() + .map_err(|_| invalid("value does not fit in u64 stroops")) + } + Value::Null => Err(invalid("value is null")), + _ => Err(invalid("expected a number or a numeric string")), + } +} + +/// Decode the base64 `SorobanTransactionData` XDR into a footprint summary. +fn decode_footprint(encoded: &str) -> Result { + let bytes = + BASE64 + .decode(encoded.trim()) + .map_err(|e| SimulationResourceError::InvalidField { + field: "transactionData".to_string(), + reason: format!("not valid base64: {}", e), + })?; + + let data = SorobanTransactionData::from_xdr(&bytes, Limits::depth(100)).map_err(|e| { + SimulationResourceError::InvalidField { + field: "transactionData".to_string(), + reason: format!("not a valid SorobanTransactionData XDR envelope: {}", e), + } + })?; + + Ok(SimulationFootprint { + read_only_entries: data.resources.footprint.read_only.len(), + read_write_entries: data.resources.footprint.read_write.len(), + read_bytes: data.resources.read_bytes, + write_bytes: data.resources.write_bytes, + instructions: data.resources.instructions, + resource_fee_stroops: data.resource_fee, + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Planning +// ───────────────────────────────────────────────────────────────────────────── + +/// Derive a submittable fee from simulation output. +/// +/// `margin_percent` is applied to the resource fees only (the inclusion fee is +/// a flat network base fee and is added afterwards). All arithmetic is +/// overflow-checked so a hostile or corrupted response cannot wrap the total. +pub fn plan_fee( + resources: &SimulationResources, + margin_percent: u32, + inclusion_fee_stroops: u64, +) -> Result { + if margin_percent > MAX_FEE_MARGIN_PERCENT { + return Err(SimulationResourceError::InvalidMargin(margin_percent)); + } + + let restore_fee_stroops = resources.restore_fee_stroops.unwrap_or(0); + let resource_total = resources + .min_resource_fee_stroops + .checked_add(restore_fee_stroops) + .ok_or(SimulationResourceError::FeeOverflow)?; + + let margin_stroops = resource_total + .checked_mul(u64::from(margin_percent)) + .ok_or(SimulationResourceError::FeeOverflow)? + / 100; + + let recommended_fee_stroops = resource_total + .checked_add(margin_stroops) + .and_then(|v| v.checked_add(inclusion_fee_stroops)) + .ok_or(SimulationResourceError::FeeOverflow)?; + + Ok(ResourceFeePlan { + min_resource_fee_stroops: resources.min_resource_fee_stroops, + restore_fee_stroops, + inclusion_fee_stroops, + margin_percent, + margin_stroops, + recommended_fee_stroops, + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Reporting +// ───────────────────────────────────────────────────────────────────────────── + +/// Render a human-readable resource + fee report using the shared print helpers. +pub fn render_report(resources: &SimulationResources, plan: &ResourceFeePlan) { + use crate::utils::print as p; + + p::header("Simulated Transaction Resources"); + p::separator(); + + p::kv( + "CPU instructions", + &resources + .cpu_instructions + .map(format_thousands) + .unwrap_or_else(|| "not reported".to_string()), + ); + p::kv( + "Memory (bytes)", + &resources + .memory_bytes + .map(format_thousands) + .unwrap_or_else(|| "not reported".to_string()), + ); + + match &resources.footprint { + Some(fp) => { + p::kv( + "Footprint entries", + &format!( + "{} total ({} read-only, {} read-write)", + fp.total_entries(), + fp.read_only_entries, + fp.read_write_entries + ), + ); + p::kv( + "Ledger read bytes", + &format_thousands(u64::from(fp.read_bytes)), + ); + p::kv( + "Ledger write bytes", + &format_thousands(u64::from(fp.write_bytes)), + ); + } + None => p::kv("Footprint", "unavailable (no transactionData)"), + } + + if let Some(ledger) = resources.latest_ledger { + p::kv("Simulated at ledger", &ledger.to_string()); + } + + p::separator(); + p::kv( + "Min resource fee", + &format!( + "{} stroops ({:.7} XLM)", + format_thousands(plan.min_resource_fee_stroops), + resources.min_resource_fee_xlm() + ), + ); + if plan.restore_fee_stroops > 0 { + p::kv( + "Restore fee", + &format!("{} stroops", format_thousands(plan.restore_fee_stroops)), + ); + } + p::kv( + &format!("Safety margin ({}%)", plan.margin_percent), + &format!("{} stroops", format_thousands(plan.margin_stroops)), + ); + p::kv( + "Inclusion fee", + &format!("{} stroops", format_thousands(plan.inclusion_fee_stroops)), + ); + p::kv_accent( + "Recommended fee", + &format!( + "{} stroops ({:.7} XLM)", + format_thousands(plan.recommended_fee_stroops), + plan.recommended_fee_xlm() + ), + ); + p::separator(); + + for warning in &resources.warnings { + p::warn(warning); + } +} + +/// Machine-readable form of a resource report, for `--json` output. +pub fn report_json(resources: &SimulationResources, plan: &ResourceFeePlan) -> Value { + serde_json::json!({ + "resources": resources, + "plan": { + "min_resource_fee_stroops": plan.min_resource_fee_stroops, + "restore_fee_stroops": plan.restore_fee_stroops, + "inclusion_fee_stroops": plan.inclusion_fee_stroops, + "margin_percent": plan.margin_percent, + "margin_stroops": plan.margin_stroops, + "recommended_fee_stroops": plan.recommended_fee_stroops, + "recommended_fee_xlm": plan.recommended_fee_xlm(), + } + }) +} + +/// Group a number with thousands separators for terminal output. +fn format_thousands(value: u64) -> String { + let digits = value.to_string(); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (i, ch) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i) % 3 == 0 { + out.push(','); + } + out.push(ch); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use stellar_xdr::curr::{ + ExtensionPoint, Hash, LedgerFootprint, LedgerKey, LedgerKeyContractCode, SorobanResources, + WriteXdr, + }; + + /// Build a real `SorobanTransactionData` XDR blob so the footprint decoder + /// is exercised against genuine XDR rather than a hand-rolled fixture. + fn transaction_data_xdr(read_only: u32, read_write: u32) -> String { + let key = |seed: u8| { + LedgerKey::ContractCode(LedgerKeyContractCode { + hash: Hash([seed; 32]), + }) + }; + let data = SorobanTransactionData { + ext: ExtensionPoint::V0, + resources: SorobanResources { + footprint: LedgerFootprint { + read_only: (0..read_only) + .map(|i| key(i as u8)) + .collect::>() + .try_into() + .unwrap(), + read_write: (0..read_write) + .map(|i| key(100 + i as u8)) + .collect::>() + .try_into() + .unwrap(), + }, + instructions: 1_234_567, + read_bytes: 4_096, + write_bytes: 512, + }, + resource_fee: 58_181, + }; + BASE64.encode(data.to_xdr(Limits::none()).unwrap()) + } + + fn full_response() -> Value { + json!({ + "latestLedger": 1_234_567u64, + "minResourceFee": "58181", + "cost": { "cpuInsns": "1274180", "memBytes": "1275072" }, + "transactionData": transaction_data_xdr(2, 1), + "events": [], + }) + } + + // ── Primary flow ──────────────────────────────────────────────────────── + + #[test] + fn parses_cpu_memory_footprint_and_min_fee() { + let res = parse_simulation_resources(&full_response()).unwrap(); + + assert_eq!(res.min_resource_fee_stroops, 58_181); + assert_eq!(res.cpu_instructions, Some(1_274_180)); + assert_eq!(res.memory_bytes, Some(1_275_072)); + assert_eq!(res.latest_ledger, Some(1_234_567)); + + let fp = res.footprint.expect("footprint decoded"); + assert_eq!(fp.read_only_entries, 2); + assert_eq!(fp.read_write_entries, 1); + assert_eq!(fp.total_entries(), 3); + assert_eq!(fp.read_bytes, 4_096); + assert_eq!(fp.write_bytes, 512); + assert_eq!(fp.instructions, 1_234_567); + assert_eq!(fp.resource_fee_stroops, 58_181); + } + + #[test] + fn plans_fee_with_default_margin() { + let res = parse_simulation_resources(&full_response()).unwrap(); + let plan = plan_fee( + &res, + DEFAULT_FEE_MARGIN_PERCENT, + DEFAULT_INCLUSION_FEE_STROOPS, + ) + .unwrap(); + + assert_eq!(plan.min_resource_fee_stroops, 58_181); + assert_eq!(plan.margin_stroops, 58_181 * 20 / 100); + assert_eq!( + plan.recommended_fee_stroops, + 58_181 + (58_181 * 20 / 100) + 100 + ); + assert!(plan.recommended_fee_xlm() > 0.0); + } + + #[test] + fn unwraps_jsonrpc_envelope() { + let envelope = json!({ + "jsonrpc": "2.0", + "id": 1, + "result": full_response(), + }); + let res = parse_simulation_resources(&envelope).unwrap(); + assert_eq!(res.min_resource_fee_stroops, 58_181); + } + + #[test] + fn accepts_numeric_min_resource_fee() { + let res = parse_simulation_resources(&json!({ + "minResourceFee": 42u64, + "transactionData": transaction_data_xdr(1, 0), + })) + .unwrap(); + assert_eq!(res.min_resource_fee_stroops, 42); + } + + #[test] + fn parses_and_reports_json() { + let res = parse_simulation_resources(&full_response()).unwrap(); + let plan = plan_fee(&res, 10, 100).unwrap(); + let doc = report_json(&res, &plan); + assert_eq!(doc["plan"]["margin_percent"], 10); + assert_eq!(doc["resources"]["min_resource_fee_stroops"], 58_181); + } + + #[test] + fn parses_from_raw_string() { + let raw = serde_json::to_string(&full_response()).unwrap(); + assert_eq!( + parse_simulation_response_str(&raw) + .unwrap() + .min_resource_fee_stroops, + 58_181 + ); + } + + // ── Boundary cases ────────────────────────────────────────────────────── + + #[test] + fn zero_margin_and_zero_fee_produce_inclusion_fee_only() { + let res = SimulationResources { + min_resource_fee_stroops: 0, + ..Default::default() + }; + let plan = plan_fee(&res, 0, DEFAULT_INCLUSION_FEE_STROOPS).unwrap(); + assert_eq!(plan.margin_stroops, 0); + assert_eq!(plan.recommended_fee_stroops, DEFAULT_INCLUSION_FEE_STROOPS); + } + + #[test] + fn max_margin_is_accepted_and_one_over_is_rejected() { + let res = SimulationResources { + min_resource_fee_stroops: 100, + ..Default::default() + }; + assert!(plan_fee(&res, MAX_FEE_MARGIN_PERCENT, 0).is_ok()); + assert_eq!( + plan_fee(&res, MAX_FEE_MARGIN_PERCENT + 1, 0).unwrap_err(), + SimulationResourceError::InvalidMargin(MAX_FEE_MARGIN_PERCENT + 1) + ); + } + + #[test] + fn saturated_fee_overflow_is_reported_not_wrapped() { + let res = SimulationResources { + min_resource_fee_stroops: u64::MAX, + ..Default::default() + }; + assert_eq!( + plan_fee(&res, 100, 0).unwrap_err(), + SimulationResourceError::FeeOverflow + ); + } + + #[test] + fn missing_cost_object_falls_back_to_transaction_data() { + let res = parse_simulation_resources(&json!({ + "minResourceFee": "1000", + "transactionData": transaction_data_xdr(0, 1), + })) + .unwrap(); + + assert_eq!(res.cpu_instructions, Some(1_234_567)); + assert_eq!(res.memory_bytes, None); + assert!(res.warnings.iter().any(|w| w.contains("cost.memBytes"))); + } + + #[test] + fn absent_transaction_data_yields_no_footprint_but_still_plans() { + let res = parse_simulation_resources(&json!({ "minResourceFee": "700" })).unwrap(); + assert!(res.footprint.is_none()); + assert_eq!(res.cpu_instructions, None); + assert!(res.warnings.iter().any(|w| w.contains("transactionData"))); + assert_eq!(plan_fee(&res, 0, 0).unwrap().recommended_fee_stroops, 700); + } + + #[test] + fn restore_preamble_is_added_to_the_plan() { + let res = parse_simulation_resources(&json!({ + "minResourceFee": "1000", + "restorePreamble": { "minResourceFee": "250", "transactionData": "" }, + })) + .unwrap(); + + assert!(res.requires_restore()); + let plan = plan_fee(&res, 0, 0).unwrap(); + assert_eq!(plan.restore_fee_stroops, 250); + assert_eq!(plan.recommended_fee_stroops, 1_250); + } + + #[test] + fn empty_footprint_decodes_to_zero_entries() { + let res = parse_simulation_resources(&json!({ + "minResourceFee": "1", + "transactionData": transaction_data_xdr(0, 0), + })) + .unwrap(); + let fp = res.footprint.unwrap(); + assert_eq!(fp.total_entries(), 0); + } + + // ── Failure cases ─────────────────────────────────────────────────────── + + #[test] + fn rejects_non_object_payloads() { + assert_eq!( + parse_simulation_resources(&json!([1, 2, 3])).unwrap_err(), + SimulationResourceError::NotAnObject + ); + assert_eq!( + parse_simulation_resources(&Value::Null).unwrap_err(), + SimulationResourceError::NotAnObject + ); + } + + #[test] + fn surfaces_host_simulation_errors() { + let err = parse_simulation_resources(&json!({ + "error": "HostError: Error(Contract, #3)", + "minResourceFee": "0", + })) + .unwrap_err(); + assert!( + matches!(err, SimulationResourceError::SimulationFailed(msg) if msg.contains("HostError")) + ); + } + + #[test] + fn surfaces_jsonrpc_transport_errors() { + let err = parse_simulation_resources(&json!({ + "jsonrpc": "2.0", + "error": { "code": -32602, "message": "invalid transaction" }, + })) + .unwrap_err(); + assert_eq!( + err, + SimulationResourceError::RpcError("invalid transaction".to_string()) + ); + } + + #[test] + fn rejects_responses_without_resource_accounting() { + let err = parse_simulation_resources(&json!({ "status": "ok" })).unwrap_err(); + assert!(matches!( + err, + SimulationResourceError::UnsupportedResponse(_) + )); + } + + #[test] + fn rejects_malformed_numeric_fields() { + for bad in [ + json!("not-a-number"), + json!(-1), + json!(1.5), + json!(""), + json!(null), + json!({}), + ] { + let err = parse_simulation_resources(&json!({ "minResourceFee": bad })).unwrap_err(); + assert!( + matches!(&err, SimulationResourceError::InvalidField { field, .. } if field == "minResourceFee"), + "expected InvalidField for {:?}, got {:?}", + bad, + err + ); + } + } + + #[test] + fn rejects_undecodable_transaction_data() { + let err = parse_simulation_resources(&json!({ + "minResourceFee": "1", + "transactionData": "!!!not base64!!!", + })) + .unwrap_err(); + assert!( + matches!(&err, SimulationResourceError::InvalidField { field, reason } + if field == "transactionData" && reason.contains("base64")) + ); + + let err = parse_simulation_resources(&json!({ + "minResourceFee": "1", + "transactionData": BASE64.encode([0xffu8; 8]), + })) + .unwrap_err(); + assert!( + matches!(&err, SimulationResourceError::InvalidField { field, .. } + if field == "transactionData") + ); + } + + #[test] + fn rejects_oversized_documents() { + let raw = format!("{{\"pad\":\"{}\"}}", "a".repeat(MAX_RESPONSE_BYTES)); + assert!(matches!( + parse_simulation_response_str(&raw).unwrap_err(), + SimulationResourceError::ResponseTooLarge { .. } + )); + } + + #[test] + fn rejects_invalid_json_text() { + assert!(matches!( + parse_simulation_response_str("{not json").unwrap_err(), + SimulationResourceError::InvalidField { .. } + )); + } + + #[test] + fn rejects_ledger_sequence_above_u32() { + let err = parse_simulation_resources(&json!({ + "minResourceFee": "1", + "latestLedger": u64::MAX, + })) + .unwrap_err(); + assert!( + matches!(&err, SimulationResourceError::InvalidField { field, .. } + if field == "latestLedger") + ); + } + + #[test] + fn restore_preamble_without_fee_is_rejected() { + let err = parse_simulation_resources(&json!({ + "minResourceFee": "1", + "restorePreamble": { "transactionData": "" }, + })) + .unwrap_err(); + assert_eq!( + err, + SimulationResourceError::MissingField("restorePreamble.minResourceFee".to_string()) + ); + } + + #[test] + fn formats_thousands_separators() { + assert_eq!(format_thousands(0), "0"); + assert_eq!(format_thousands(999), "999"); + assert_eq!(format_thousands(1_000), "1,000"); + assert_eq!(format_thousands(1_274_180), "1,274,180"); + } +} diff --git a/src/utils/soroban.rs b/src/utils/soroban.rs index 5423f364..b1a03e59 100644 --- a/src/utils/soroban.rs +++ b/src/utils/soroban.rs @@ -1,4 +1,7 @@ use crate::utils::config::{self, WalletEntry}; +use crate::utils::simulation_resources::{ + self, ResourceFeePlan, SimulationResourceError, SimulationResources, +}; use crate::utils::wallet_signer::{self, SigningRequest}; use anyhow::{Context, Result}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; @@ -27,10 +30,35 @@ static HTTP_CLIENT: Lazy = Lazy::new(|| { #[derive(Debug, Serialize, Deserialize)] pub struct SimulationResult { pub return_value: String, + /// Minimum resource fee (stroops) reported by simulation, falling back to + /// [`FALLBACK_FEE_STROOPS`] when the RPC server did not report one. pub fee: u64, pub events: Vec, #[serde(default)] pub errors: Vec, + /// Full resource accounting (CPU, memory, footprint, minimum resource fee) + /// when the response carried it. `None` on servers that do not implement + /// Soroban resource reporting, or when the payload could not be parsed — + /// in that case the reason is appended to `errors`. + #[serde(default)] + pub resources: Option, +} + +impl SimulationResult { + /// Build a fee plan from the simulated resources. + /// + /// Returns `None` when the response carried no resource accounting, so + /// callers can fall back to their own estimate instead of reporting a + /// fabricated number. + pub fn fee_plan(&self, margin_percent: u32) -> Option { + let resources = self.resources.as_ref()?; + simulation_resources::plan_fee( + resources, + margin_percent, + simulation_resources::DEFAULT_INCLUSION_FEE_STROOPS, + ) + .ok() + } } #[derive(Debug, Serialize, Deserialize)] @@ -150,17 +178,8 @@ pub async fn simulate_transaction( .await .context("Simulation request failed")?; - // Parse the simulation result - let return_value = decode_return_value(&result)?; - let fee = extract_fee(&result)?; - let events = extract_events(&result)?; - - Ok(SimulationResult { - return_value, - fee, - events, - errors: extract_simulation_errors(&result), - }) + // Parse the simulation result (resources, fee, events, errors). + build_simulation_result(&result) } pub async fn simulate_deploy_transaction( @@ -182,12 +201,7 @@ pub async fn simulate_deploy_transaction( .await .context("Deploy simulation request failed")?; - Ok(SimulationResult { - return_value: decode_return_value(&result)?, - fee: extract_fee(&result)?, - events: extract_events(&result)?, - errors: extract_simulation_errors(&result), - }) + build_simulation_result(&result) } pub async fn submit_transaction( @@ -533,14 +547,56 @@ fn decode_return_value(result: &serde_json::Value) -> Result { } } -fn extract_fee(result: &serde_json::Value) -> Result { - // Extract fee from simulation result - if let Some(cost) = result.get("cost") { - if let Some(fee) = cost.get("cpuInsns") { - return Ok(fee.as_u64().unwrap_or(100000)); // Default fee +/// Fee used when the RPC server reports no resource accounting at all. +/// +/// Only a last resort: a real `minResourceFee` from simulation always wins. +pub const FALLBACK_FEE_STROOPS: u64 = 100_000; + +/// Parse the resource accounting out of a `simulateTransaction` response. +/// +/// The parse failure is deliberately not fatal: a fee estimate is still more +/// useful than aborting, so the caller surfaces the reason through +/// `SimulationResult::errors` and falls back to [`FALLBACK_FEE_STROOPS`]. +fn extract_resources( + result: &serde_json::Value, +) -> std::result::Result { + simulation_resources::parse_simulation_resources(result) +} + +/// Minimum resource fee in stroops for this simulation. +/// +/// Prefers the `minResourceFee` the RPC server reported. Previous releases +/// mistakenly reported `cost.cpuInsns` (an instruction count, not a fee) here. +fn extract_fee(resources: Option<&SimulationResources>) -> u64 { + resources + .map(|r| r.min_resource_fee_stroops) + .unwrap_or(FALLBACK_FEE_STROOPS) +} + +/// Assemble a [`SimulationResult`] from a raw RPC response. +fn build_simulation_result(result: &serde_json::Value) -> Result { + let mut errors = extract_simulation_errors(result); + + let resources = match extract_resources(result) { + Ok(resources) => Some(resources), + Err(e) => { + // A host-level simulation failure is already reported through + // `errors`; only add parse problems that are not duplicates. + let message = format!("resource accounting unavailable: {}", e); + if !errors.iter().any(|existing| existing.contains(&message)) { + errors.push(message); + } + None } - } - Ok(100000) // Default fee in stroops + }; + + Ok(SimulationResult { + return_value: decode_return_value(result)?, + fee: extract_fee(resources.as_ref()), + events: extract_events(result)?, + errors, + resources, + }) } fn extract_events(result: &serde_json::Value) -> Result> { @@ -955,8 +1011,11 @@ mod tests { let return_value = decode_return_value(&result).unwrap(); assert_eq!(return_value, "success_value"); - let fee = extract_fee(&result).unwrap(); - assert_eq!(fee, 150000); + // The fee is the RPC's `minResourceFee`, not the CPU instruction count. + let resources = extract_resources(&result).unwrap(); + assert_eq!(extract_fee(Some(&resources)), 58_181); + assert_eq!(resources.cpu_instructions, Some(150_000)); + assert_eq!(resources.memory_bytes, Some(2_048)); let events = extract_events(&result).unwrap(); assert_eq!(events.len(), 2); @@ -967,6 +1026,62 @@ mod tests { assert!(errors.is_empty()); } + #[test] + fn simulation_result_reports_resources_and_fee_plan() { + let fixture = read_fixture("simulate_success.json"); + let response: SorobanRpcResponse = + serde_json::from_str(&fixture).expect("failed to deserialize simulate_success.json"); + let result = response.result.expect("missing result in response"); + + let simulation = build_simulation_result(&result).unwrap(); + + assert_eq!(simulation.fee, 58_181); + let resources = simulation.resources.as_ref().expect("resources parsed"); + assert_eq!(resources.cpu_instructions, Some(150_000)); + assert_eq!(resources.memory_bytes, Some(2_048)); + + let footprint = resources.footprint.as_ref().expect("footprint decoded"); + assert_eq!(footprint.read_only_entries, 2); + assert_eq!(footprint.read_write_entries, 1); + assert_eq!(footprint.read_bytes, 8_192); + assert_eq!(footprint.write_bytes, 1_024); + + let plan = simulation.fee_plan(20).expect("fee plan"); + assert_eq!(plan.min_resource_fee_stroops, 58_181); + assert!(plan.recommended_fee_stroops > 58_181); + } + + #[test] + fn simulation_without_resource_accounting_falls_back_to_default_fee() { + // A response from a non-Soroban endpoint: no minResourceFee, no + // transactionData. The fee must fall back rather than be invented. + let result = serde_json::json!({ "returnValue": "ok", "events": [] }); + let simulation = build_simulation_result(&result).unwrap(); + + assert_eq!(simulation.fee, FALLBACK_FEE_STROOPS); + assert!(simulation.resources.is_none()); + assert!(simulation + .errors + .iter() + .any(|e| e.contains("resource accounting unavailable"))); + } + + #[test] + fn simulation_surfaces_host_errors_without_resources() { + let result = serde_json::json!({ + "error": "HostError: Error(Budget, ExceededLimit)", + "events": [], + }); + let simulation = build_simulation_result(&result).unwrap(); + + assert!(simulation.resources.is_none()); + assert!(simulation.fee_plan(20).is_none()); + assert!(simulation + .errors + .iter() + .any(|e| e.contains("ExceededLimit"))); + } + #[test] fn test_parse_simulate_error_top_level() { let fixture = read_fixture("simulate_error_top_level.json"); diff --git a/src/utils/wallet_import.rs b/src/utils/wallet_import.rs new file mode 100644 index 00000000..5d058c07 --- /dev/null +++ b/src/utils/wallet_import.rs @@ -0,0 +1,828 @@ +//! Pure parsers for wallet import and backup payloads. +//! +//! `starforge wallet import --file` and `starforge backup restore` both accept +//! files that arrive from outside the tool: an exported wallet backup, possibly +//! wrapped in an encrypted bundle. That makes them a trust boundary, so the +//! parsing lives here — separated from prompting, disk access, and the config +//! store — where it can be unit-tested, property-tested, and fuzzed. +//! +//! The harnesses under `fuzz/fuzz_targets/` drive these functions directly: +//! +//! ```text +//! cargo fuzz run fuzz_wallet_backup_parse +//! cargo fuzz run fuzz_wallet_import_envelope +//! ``` +//! +//! ## Guarantees +//! +//! Every function here is total: for **any** input — malformed JSON, truncated +//! ciphertext, invalid StrKeys, multi-megabyte blobs, or hostile Unicode — it +//! returns a [`WalletImportError`] rather than panicking, and never allocates +//! proportionally to an attacker-chosen length before the size check runs. +//! +//! ## Security +//! +//! - Size limits are enforced *before* parsing, so an oversized file cannot +//! drive the JSON parser into a large allocation. +//! - Error messages never echo secret key material; only the wallet name and +//! the failure reason are reported. +//! - Wallet names containing bidirectional or zero-width control characters are +//! rejected: they can make one wallet's name render identically to another's. +//! Non-ASCII names are accepted but reported as a warning for the same +//! reason — rejecting them outright would break backups made by earlier +//! releases, which allow any Unicode alphanumeric. + +use serde::{Deserialize, Serialize}; + +use crate::utils::config; + +/// Backup schema version this build writes and accepts. +pub const WALLET_BACKUP_VERSION: &str = "1"; + +/// Largest backup document accepted, in bytes. +pub const MAX_BACKUP_BYTES: usize = 4 * 1024 * 1024; + +/// Largest number of wallets accepted in one backup. +pub const MAX_WALLETS_PER_BACKUP: usize = 1_000; + +/// Longest wallet name accepted from a backup file. +pub const MAX_WALLET_NAME_LEN: usize = 64; + +/// Largest encrypted bundle accepted, in bytes. +pub const MAX_ENVELOPE_BYTES: usize = MAX_BACKUP_BYTES * 2; + +/// Salt length written by [`crate::utils::crypto::encrypt_secret`]. +pub const SALT_LEN: usize = 16; + +/// AES-GCM nonce length. +pub const NONCE_LEN: usize = 12; + +/// AES-GCM authentication tag length: a ciphertext shorter than this cannot +/// even carry a tag and is therefore truncated. +pub const GCM_TAG_LEN: usize = 16; + +// ───────────────────────────────────────────────────────────────────────────── +// Errors +// ───────────────────────────────────────────────────────────────────────────── + +/// Why an import payload was refused. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WalletImportError { + /// Input exceeded a size limit. + TooLarge { bytes: usize, limit: usize }, + /// Input was empty or whitespace only. + Empty, + /// The document was not valid JSON. + MalformedJson(String), + /// The backup declares a version this build cannot read. + UnsupportedVersion { found: String, expected: String }, + /// The backup contained no wallets. + NoWallets, + /// The backup contained more wallets than [`MAX_WALLETS_PER_BACKUP`]. + TooManyWallets { count: usize, limit: usize }, + /// Two entries share a name. + DuplicateWallet(String), + /// A wallet entry failed validation. + InvalidEntry { wallet: String, reason: String }, + /// A wallet name carried invisible or direction-changing characters. + DeceptiveWalletName { wallet: String, reason: String }, + /// The encrypted bundle did not have 3, 5, or 6 colon-separated parts. + MalformedEnvelope { parts: usize }, + /// A base64 field of the bundle did not decode. + InvalidBase64 { field: &'static str }, + /// A bundle field had the wrong decoded length. + InvalidFieldLength { + field: &'static str, + len: usize, + expected: usize, + }, + /// The ciphertext is too short to carry an authentication tag. + TruncatedCiphertext { len: usize, minimum: usize }, + /// A KDF parameter was absent, non-numeric, or zero. + InvalidKdfParameter { field: &'static str, reason: String }, +} + +impl std::fmt::Display for WalletImportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::TooLarge { bytes, limit } => write!( + f, + "input is {} bytes, above the {} byte limit for an import payload", + bytes, limit + ), + Self::Empty => write!(f, "input is empty"), + Self::MalformedJson(msg) => write!(f, "invalid backup JSON: {}", msg), + Self::UnsupportedVersion { found, expected } => write!( + f, + "unsupported backup version '{}'; this build reads version '{}'", + found, expected + ), + Self::NoWallets => write!(f, "backup contains no wallets"), + Self::TooManyWallets { count, limit } => write!( + f, + "backup contains {} wallets, above the limit of {}", + count, limit + ), + Self::DuplicateWallet(name) => { + write!(f, "duplicate wallet '{}' in backup file", name) + } + Self::InvalidEntry { wallet, reason } => { + write!(f, "wallet '{}' is invalid: {}", wallet, reason) + } + Self::DeceptiveWalletName { wallet, reason } => write!( + f, + "wallet name {:?} is rejected: {}", + wallet.escape_debug().to_string(), + reason + ), + Self::MalformedEnvelope { parts } => write!( + f, + "encrypted bundle has {} colon-separated parts; expected 3, 5, or 6", + parts + ), + Self::InvalidBase64 { field } => { + write!(f, "encrypted bundle field `{}` is not valid base64", field) + } + Self::InvalidFieldLength { + field, + len, + expected, + } => write!( + f, + "encrypted bundle field `{}` decoded to {} bytes; expected {}", + field, len, expected + ), + Self::TruncatedCiphertext { len, minimum } => write!( + f, + "ciphertext is {} bytes; at least {} are needed for the authentication tag", + len, minimum + ), + Self::InvalidKdfParameter { field, reason } => { + write!(f, "KDF parameter `{}` is invalid: {}", field, reason) + } + } + } +} + +impl std::error::Error for WalletImportError {} + +type Result = std::result::Result; + +// ───────────────────────────────────────────────────────────────────────────── +// Backup documents +// ───────────────────────────────────────────────────────────────────────────── + +/// A wallet backup document, as written by `starforge wallet export`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WalletBackup { + pub version: String, + pub exported_at: String, + pub wallets: Vec, +} + +/// One wallet inside a backup document. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WalletBackupEntry { + pub name: String, + pub public_key: String, + pub secret_key: Option, + pub network: String, + pub created_at: String, + pub funded: bool, +} + +/// A parsed backup plus any non-fatal observations about it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedBackup { + pub backup: WalletBackup, + /// Notes worth showing the user, e.g. a wallet name that could be + /// confused with another one. + pub warnings: Vec, +} + +/// Whether an import payload is an encrypted bundle or a plaintext document. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PayloadKind { + /// `salt:nonce:ciphertext[:mem:iters[:parallelism]]` + Encrypted, + /// A bare JSON document. + Plaintext, +} + +/// Classify an import payload. +/// +/// Earlier releases detected encryption with `raw.matches(':').count() == 2`, +/// which misclassified the 5- and 6-part bundles written when custom Argon2 +/// parameters are configured — those were handed to the JSON parser and failed +/// with a confusing "Invalid backup JSON format". Classification now mirrors +/// the bundle grammar, and a JSON document (which always starts with `{` or +/// `[`) is never treated as a bundle regardless of how many colons it holds. +pub fn classify_payload(raw: &str) -> PayloadKind { + let trimmed = raw.trim(); + + if trimmed.starts_with('{') || trimmed.starts_with('[') { + return PayloadKind::Plaintext; + } + + let parts: Vec<&str> = trimmed.split(':').collect(); + if matches!(parts.len(), 3 | 5 | 6) + && parts + .iter() + .take(3) + .all(|part| !part.is_empty() && part.bytes().all(is_base64_byte)) + { + return PayloadKind::Encrypted; + } + + PayloadKind::Plaintext +} + +fn is_base64_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'=' +} + +// ───────────────────────────────────────────────────────────────────────────── +// Encrypted envelope +// ───────────────────────────────────────────────────────────────────────────── + +/// A structurally valid encrypted bundle. +/// +/// Structural validity says nothing about whether the passphrase is correct — +/// that is decided by AES-GCM during decryption. The point of parsing first is +/// to reject garbage before spending an Argon2 key derivation on it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EncryptedEnvelope { + pub salt: Vec, + pub nonce: Vec, + pub ciphertext: Vec, + pub mem_cost: Option, + pub iterations: Option, + pub parallelism: Option, +} + +/// Parse and structurally validate an encrypted bundle. +pub fn parse_encrypted_envelope(raw: &str) -> Result { + if raw.len() > MAX_ENVELOPE_BYTES { + return Err(WalletImportError::TooLarge { + bytes: raw.len(), + limit: MAX_ENVELOPE_BYTES, + }); + } + + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(WalletImportError::Empty); + } + + let parts: Vec<&str> = trimmed.split(':').collect(); + if !matches!(parts.len(), 3 | 5 | 6) { + return Err(WalletImportError::MalformedEnvelope { parts: parts.len() }); + } + + let salt = decode_field(parts[0], "salt")?; + let nonce = decode_field(parts[1], "nonce")?; + let ciphertext = decode_field(parts[2], "ciphertext")?; + + if salt.len() != SALT_LEN { + return Err(WalletImportError::InvalidFieldLength { + field: "salt", + len: salt.len(), + expected: SALT_LEN, + }); + } + if nonce.len() != NONCE_LEN { + return Err(WalletImportError::InvalidFieldLength { + field: "nonce", + len: nonce.len(), + expected: NONCE_LEN, + }); + } + if ciphertext.len() < GCM_TAG_LEN { + return Err(WalletImportError::TruncatedCiphertext { + len: ciphertext.len(), + minimum: GCM_TAG_LEN, + }); + } + + let (mem_cost, iterations) = if parts.len() >= 5 { + ( + Some(parse_kdf_param(parts[3], "mem")?), + Some(parse_kdf_param(parts[4], "iterations")?), + ) + } else { + (None, None) + }; + let parallelism = if parts.len() == 6 { + Some(parse_kdf_param(parts[5], "parallelism")?) + } else { + None + }; + + Ok(EncryptedEnvelope { + salt, + nonce, + ciphertext, + mem_cost, + iterations, + parallelism, + }) +} + +fn decode_field(value: &str, field: &'static str) -> Result> { + use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; + BASE64 + .decode(value) + .map_err(|_| WalletImportError::InvalidBase64 { field }) +} + +fn parse_kdf_param(value: &str, field: &'static str) -> Result { + let parsed = value + .parse::() + .map_err(|_| WalletImportError::InvalidKdfParameter { + field, + reason: "must be a decimal u32".to_string(), + })?; + if parsed == 0 { + return Err(WalletImportError::InvalidKdfParameter { + field, + reason: "must be greater than zero".to_string(), + }); + } + Ok(parsed) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Backup parsing +// ───────────────────────────────────────────────────────────────────────────── + +/// Parse and validate a plaintext wallet backup document. +pub fn parse_wallet_backup(contents: &str) -> Result { + if contents.len() > MAX_BACKUP_BYTES { + return Err(WalletImportError::TooLarge { + bytes: contents.len(), + limit: MAX_BACKUP_BYTES, + }); + } + if contents.trim().is_empty() { + return Err(WalletImportError::Empty); + } + + let backup: WalletBackup = serde_json::from_str(contents) + .map_err(|e| WalletImportError::MalformedJson(e.to_string()))?; + + if backup.version != WALLET_BACKUP_VERSION { + return Err(WalletImportError::UnsupportedVersion { + found: backup.version.clone(), + expected: WALLET_BACKUP_VERSION.to_string(), + }); + } + if backup.wallets.is_empty() { + return Err(WalletImportError::NoWallets); + } + if backup.wallets.len() > MAX_WALLETS_PER_BACKUP { + return Err(WalletImportError::TooManyWallets { + count: backup.wallets.len(), + limit: MAX_WALLETS_PER_BACKUP, + }); + } + + let mut warnings = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for entry in &backup.wallets { + check_wallet_name(&entry.name)?; + if !seen.insert(entry.name.as_str()) { + return Err(WalletImportError::DuplicateWallet(entry.name.clone())); + } + if !entry.name.is_ascii() { + warnings.push(format!( + "wallet '{}' has a non-ASCII name, which can render identically to another name", + entry.name + )); + } + validate_entry(entry)?; + } + + Ok(ParsedBackup { backup, warnings }) +} + +/// Reject wallet names that are invisible, direction-changing, or overlong. +/// +/// Length is checked in `char`s: a name of 64 astral characters is 256 bytes, +/// and the limit is about what a human can read, not about storage. +fn check_wallet_name(name: &str) -> Result<()> { + if name.is_empty() { + return Err(WalletImportError::DeceptiveWalletName { + wallet: name.to_string(), + reason: "name is empty".to_string(), + }); + } + if name.chars().count() > MAX_WALLET_NAME_LEN { + return Err(WalletImportError::DeceptiveWalletName { + wallet: name.chars().take(16).collect(), + reason: format!( + "name is {} characters; at most {} are allowed", + name.chars().count(), + MAX_WALLET_NAME_LEN + ), + }); + } + if let Some(bad) = name.chars().find(|c| is_deceptive_char(*c)) { + return Err(WalletImportError::DeceptiveWalletName { + wallet: name.to_string(), + reason: format!( + "contains U+{:04X}, an invisible or direction-changing character", + bad as u32 + ), + }); + } + Ok(()) +} + +/// Characters that are invisible or that reorder the rendering of a name. +fn is_deceptive_char(c: char) -> bool { + c.is_control() + || matches!(c, + '\u{200B}'..='\u{200F}' // zero width space … RTL mark + | '\u{202A}'..='\u{202E}' // bidi embedding / override + | '\u{2066}'..='\u{2069}' // bidi isolates + | '\u{FEFF}' // zero width no-break space + | '\u{00AD}' // soft hyphen + ) +} + +/// Validate a single backup entry against the wallet rules. +/// +/// Errors quote the wallet name and the reason, never the key material. +pub fn validate_entry(entry: &WalletBackupEntry) -> Result<()> { + config::validate_wallet_name(&entry.name).map_err(|e| WalletImportError::InvalidEntry { + wallet: entry.name.clone(), + reason: e.to_string(), + })?; + config::validate_public_key(&entry.public_key).map_err(|e| { + WalletImportError::InvalidEntry { + wallet: entry.name.clone(), + reason: e.to_string(), + } + })?; + if let Some(secret) = &entry.secret_key { + config::validate_secret_key(secret).map_err(|e| WalletImportError::InvalidEntry { + wallet: entry.name.clone(), + reason: e.to_string(), + })?; + } + if entry.network.trim().is_empty() { + return Err(WalletImportError::InvalidEntry { + wallet: entry.name.clone(), + reason: "network is empty".to_string(), + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_public_key() -> String { + format!("G{}", "A".repeat(55)) + } + + fn valid_secret_key() -> String { + format!("S{}", "B".repeat(55)) + } + + fn backup_json(wallets: &str) -> String { + format!( + r#"{{"version":"1","exported_at":"2026-07-29T00:00:00Z","wallets":[{}]}}"#, + wallets + ) + } + + fn wallet_json(name: &str) -> String { + format!( + r#"{{"name":"{}","public_key":"{}","secret_key":"{}","network":"testnet","created_at":"2026-07-29T00:00:00Z","funded":true}}"#, + name, + valid_public_key(), + valid_secret_key() + ) + } + + fn envelope(salt: usize, nonce: usize, ct: usize) -> String { + use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; + format!( + "{}:{}:{}", + BASE64.encode(vec![1u8; salt]), + BASE64.encode(vec![2u8; nonce]), + BASE64.encode(vec![3u8; ct]) + ) + } + + // ── Primary flow ──────────────────────────────────────────────────────── + + #[test] + fn parses_a_well_formed_backup() { + let parsed = parse_wallet_backup(&backup_json(&wallet_json("alice"))).unwrap(); + + assert_eq!(parsed.backup.version, "1"); + assert_eq!(parsed.backup.wallets.len(), 1); + assert_eq!(parsed.backup.wallets[0].name, "alice"); + assert!(parsed.warnings.is_empty()); + } + + #[test] + fn parses_a_well_formed_envelope() { + let env = parse_encrypted_envelope(&envelope(SALT_LEN, NONCE_LEN, 64)).unwrap(); + + assert_eq!(env.salt.len(), SALT_LEN); + assert_eq!(env.nonce.len(), NONCE_LEN); + assert_eq!(env.ciphertext.len(), 64); + assert_eq!(env.mem_cost, None); + } + + #[test] + fn parses_a_six_part_envelope_with_kdf_parameters() { + let raw = format!("{}:65536:3:4", envelope(SALT_LEN, NONCE_LEN, 32)); + let env = parse_encrypted_envelope(&raw).unwrap(); + + assert_eq!(env.mem_cost, Some(65_536)); + assert_eq!(env.iterations, Some(3)); + assert_eq!(env.parallelism, Some(4)); + } + + #[test] + fn classifies_bundles_and_documents() { + assert_eq!( + classify_payload(&envelope(SALT_LEN, NONCE_LEN, 32)), + PayloadKind::Encrypted + ); + // 5- and 6-part bundles were misclassified as plaintext before #697. + assert_eq!( + classify_payload(&format!("{}:65536:3", envelope(SALT_LEN, NONCE_LEN, 32))), + PayloadKind::Encrypted + ); + assert_eq!( + classify_payload(&format!("{}:65536:3:4", envelope(SALT_LEN, NONCE_LEN, 32))), + PayloadKind::Encrypted + ); + assert_eq!( + classify_payload(&backup_json(&wallet_json("alice"))), + PayloadKind::Plaintext + ); + // A JSON document with colons in its values is still a document. + assert_eq!(classify_payload(r#"{"a":"b:c:d"}"#), PayloadKind::Plaintext); + } + + // ── Boundary cases ────────────────────────────────────────────────────── + + #[test] + fn ciphertext_of_exactly_one_tag_is_accepted_and_one_byte_less_is_not() { + assert!(parse_encrypted_envelope(&envelope(SALT_LEN, NONCE_LEN, GCM_TAG_LEN)).is_ok()); + assert_eq!( + parse_encrypted_envelope(&envelope(SALT_LEN, NONCE_LEN, GCM_TAG_LEN - 1)).unwrap_err(), + WalletImportError::TruncatedCiphertext { + len: GCM_TAG_LEN - 1, + minimum: GCM_TAG_LEN, + } + ); + } + + #[test] + fn a_backup_at_the_wallet_limit_is_accepted_and_one_over_is_not() { + let at_limit = (0..MAX_WALLETS_PER_BACKUP) + .map(|i| wallet_json(&format!("w{}", i))) + .collect::>() + .join(","); + assert!(parse_wallet_backup(&backup_json(&at_limit)).is_ok()); + + let over = (0..=MAX_WALLETS_PER_BACKUP) + .map(|i| wallet_json(&format!("w{}", i))) + .collect::>() + .join(","); + assert!(matches!( + parse_wallet_backup(&backup_json(&over)).unwrap_err(), + WalletImportError::TooManyWallets { .. } + )); + } + + #[test] + fn a_name_at_the_length_limit_is_accepted_and_one_over_is_not() { + let at_limit = "a".repeat(MAX_WALLET_NAME_LEN); + assert!(parse_wallet_backup(&backup_json(&wallet_json(&at_limit))).is_ok()); + + let over = "a".repeat(MAX_WALLET_NAME_LEN + 1); + assert!(matches!( + parse_wallet_backup(&backup_json(&wallet_json(&over))).unwrap_err(), + WalletImportError::DeceptiveWalletName { .. } + )); + } + + #[test] + fn an_oversized_document_is_rejected_before_parsing() { + let big = "x".repeat(MAX_BACKUP_BYTES + 1); + assert_eq!( + parse_wallet_backup(&big).unwrap_err(), + WalletImportError::TooLarge { + bytes: MAX_BACKUP_BYTES + 1, + limit: MAX_BACKUP_BYTES, + } + ); + } + + // ── Failure cases ─────────────────────────────────────────────────────── + + #[test] + fn malformed_json_is_rejected() { + for bad in [ + "{", + "{\"version\":}", + "[]", + "null", + "\"just a string\"", + "{\"version\":\"1\"}", + ] { + assert!( + matches!( + parse_wallet_backup(bad), + Err(WalletImportError::MalformedJson(_)) + ), + "accepted malformed JSON {:?}", + bad + ); + } + } + + #[test] + fn empty_input_is_rejected() { + assert_eq!( + parse_wallet_backup(" ").unwrap_err(), + WalletImportError::Empty + ); + assert_eq!( + parse_encrypted_envelope(" ").unwrap_err(), + WalletImportError::Empty + ); + } + + #[test] + fn an_unsupported_version_is_rejected() { + let doc = + backup_json(&wallet_json("alice")).replace("\"version\":\"1\"", "\"version\":\"9\""); + assert_eq!( + parse_wallet_backup(&doc).unwrap_err(), + WalletImportError::UnsupportedVersion { + found: "9".to_string(), + expected: "1".to_string(), + } + ); + } + + #[test] + fn an_empty_wallet_list_is_rejected() { + assert_eq!( + parse_wallet_backup(&backup_json("")).unwrap_err(), + WalletImportError::NoWallets + ); + } + + #[test] + fn duplicate_wallet_names_are_rejected() { + let doc = backup_json(&format!( + "{},{}", + wallet_json("alice"), + wallet_json("alice") + )); + assert_eq!( + parse_wallet_backup(&doc).unwrap_err(), + WalletImportError::DuplicateWallet("alice".to_string()) + ); + } + + #[test] + fn invalid_strkeys_are_rejected() { + let doc = backup_json(&wallet_json("alice").replace(&valid_public_key(), "GNOTAKEY")); + assert!(matches!( + parse_wallet_backup(&doc).unwrap_err(), + WalletImportError::InvalidEntry { .. } + )); + + let doc = backup_json(&wallet_json("alice").replace(&valid_secret_key(), "S123")); + assert!(matches!( + parse_wallet_backup(&doc).unwrap_err(), + WalletImportError::InvalidEntry { .. } + )); + } + + #[test] + fn an_error_never_echoes_the_secret_key() { + let secret = valid_secret_key(); + let doc = backup_json(&wallet_json("alice").replace(&valid_public_key(), "GBAD")); + let err = parse_wallet_backup(&doc).unwrap_err().to_string(); + + assert!( + !err.contains(&secret), + "secret key leaked into the error: {}", + err + ); + } + + #[test] + fn bidi_and_zero_width_names_are_rejected() { + for name in [ + "al\u{202E}ice", // right-to-left override + "al\u{200B}ice", // zero width space + "al\u{FEFF}ice", // BOM + "al\u{00AD}ice", // soft hyphen + "al\u{2066}ice", // bidi isolate + ] { + let doc = backup_json(&wallet_json(name)); + assert!( + matches!( + parse_wallet_backup(&doc), + Err(WalletImportError::DeceptiveWalletName { .. }) + ), + "accepted deceptive name {:?}", + name + ); + } + } + + #[test] + fn a_non_ascii_name_is_accepted_but_warned_about() { + // Cyrillic 'а' renders like Latin 'a'. + let doc = backup_json(&wallet_json("\u{0430}lice")); + let parsed = parse_wallet_backup(&doc).unwrap(); + + assert_eq!(parsed.warnings.len(), 1); + assert!(parsed.warnings[0].contains("non-ASCII")); + } + + #[test] + fn truncated_and_corrupt_envelopes_are_rejected() { + // Wrong number of parts. + assert!(matches!( + parse_encrypted_envelope("onlyonepart"), + Err(WalletImportError::MalformedEnvelope { parts: 1 }) + )); + assert!(matches!( + parse_encrypted_envelope("a:b:c:d"), + Err(WalletImportError::MalformedEnvelope { parts: 4 }) + )); + // Not base64. + assert_eq!( + parse_encrypted_envelope("!!!:@@@:###").unwrap_err(), + WalletImportError::InvalidBase64 { field: "salt" } + ); + // Wrong salt / nonce length. + assert!(matches!( + parse_encrypted_envelope(&envelope(8, NONCE_LEN, 32)), + Err(WalletImportError::InvalidFieldLength { field: "salt", .. }) + )); + assert!(matches!( + parse_encrypted_envelope(&envelope(SALT_LEN, 4, 32)), + Err(WalletImportError::InvalidFieldLength { field: "nonce", .. }) + )); + } + + #[test] + fn invalid_kdf_parameters_are_rejected() { + let base = envelope(SALT_LEN, NONCE_LEN, 32); + assert!(matches!( + parse_encrypted_envelope(&format!("{}:notanumber:3", base)), + Err(WalletImportError::InvalidKdfParameter { field: "mem", .. }) + )); + assert!(matches!( + parse_encrypted_envelope(&format!("{}:65536:0", base)), + Err(WalletImportError::InvalidKdfParameter { + field: "iterations", + .. + }) + )); + assert!(matches!( + parse_encrypted_envelope(&format!("{}:65536:3:99999999999", base)), + Err(WalletImportError::InvalidKdfParameter { + field: "parallelism", + .. + }) + )); + } + + #[test] + fn parsers_are_total_over_hostile_input() { + // Everything here must return an error, never panic. + let inputs = [ + String::new(), + "\u{0}\u{1}\u{2}".to_string(), + "\u{FFFD}".repeat(100), + ":".repeat(1000), + "{".repeat(5000), + "\u{1F600}".repeat(500), + format!("{}\u{0}", envelope(SALT_LEN, NONCE_LEN, 32)), + ]; + for input in &inputs { + let _ = parse_wallet_backup(input); + let _ = parse_encrypted_envelope(input); + let _ = classify_payload(input); + } + } +} diff --git a/tests/config_property_tests.rs b/tests/config_property_tests.rs new file mode 100644 index 00000000..b1bf0144 --- /dev/null +++ b/tests/config_property_tests.rs @@ -0,0 +1,659 @@ +//! Property-based tests for configuration round trips (issue #696). +//! +//! These tests generate hundreds of syntactically diverse configurations and +//! assert two families of properties: +//! +//! 1. **Preservation** — a valid configuration survives every parse / +//! serialize / merge path unchanged. TOML and JSON round trips are exact, +//! merging with an empty overlay is the identity, and merging is idempotent. +//! 2. **Rejection** — malformed *combinations* (values that are individually +//! well-formed but invalid together) are refused, not silently repaired. +//! An unknown network reference, a duplicate wallet name, a non-HTTP +//! endpoint, or an unknown overlay key must all fail. +//! +//! Run with: +//! cargo test --test config_property_tests +//! +//! Deeper coverage: +//! PROPTEST_CASES=10000 cargo test --test config_property_tests + +#![allow(dead_code, unused_imports)] + +use proptest::prelude::*; +use starforge::utils::config::{ + self, AiTelemetryConfig, Config, ConfigOverlay, FeatureFlagsConfig, NetworkConfig, + PluginTrustConfig, WalletEntry, WalletRotationRecord, +}; +use std::collections::HashMap; + +// ───────────────────────────────────────────────────────────────────────────── +// Strategies +// ───────────────────────────────────────────────────────────────────────────── + +const STELLAR_CHARSET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +fn stellar_chars(len: usize) -> impl Strategy { + proptest::collection::vec(proptest::sample::select(STELLAR_CHARSET.as_bytes()), len) + .prop_map(|v| String::from_utf8(v).unwrap()) +} + +fn public_key() -> impl Strategy { + stellar_chars(55).prop_map(|s| format!("G{}", s)) +} + +fn secret_key() -> impl Strategy { + stellar_chars(55).prop_map(|s| format!("S{}", s)) +} + +/// A base64 field of `blocks` four-character groups, so the length is always a +/// multiple of four and the value decodes without padding errors. +fn base64_field(blocks: std::ops::Range) -> impl Strategy { + proptest::collection::vec("[A-Za-z0-9+/]{4}", blocks).prop_map(|groups| groups.concat()) +} + +/// An encrypted secret bundle (`salt:nonce:ciphertext`), the other shape a +/// stored secret is allowed to take. +fn encrypted_bundle() -> impl Strategy { + (base64_field(1..6), base64_field(1..6), base64_field(1..12)) + .prop_map(|(salt, nonce, ct)| format!("{}:{}:{}", salt, nonce, ct)) +} + +fn wallet_name() -> impl Strategy { + "[A-Za-z0-9_-]{1,24}" +} + +fn network_name() -> impl Strategy { + "[a-z][a-z0-9-]{0,15}" +} + +fn http_url() -> impl Strategy { + ( + proptest::sample::select(vec!["http", "https"]), + "[a-z][a-z0-9.-]{2,24}", + proptest::option::of(1024u16..=65535), + ) + .prop_map(|(scheme, host, port)| match port { + Some(p) => format!("{}://{}:{}", scheme, host, p), + None => format!("{}://{}", scheme, host), + }) +} + +fn network_config() -> impl Strategy { + ( + http_url(), + proptest::option::of(http_url()), + proptest::option::of(http_url()), + proptest::option::of("[A-Za-z0-9 ;]{0,40}"), + ) + .prop_map( + |(horizon_url, soroban_rpc_url, friendbot_url, passphrase)| NetworkConfig { + horizon_url, + soroban_rpc_url, + friendbot_url, + passphrase, + }, + ) +} + +fn rotation_record() -> impl Strategy { + ( + "[0-9]{4}-[0-1][0-9]-[0-3][0-9]T00:00:00Z", + public_key(), + network_name(), + any::(), + proptest::option::of(secret_key()), + ) + .prop_map( + |( + rotated_at, + previous_public_key, + previous_network, + previous_funded, + previous_secret_key, + )| { + WalletRotationRecord { + rotated_at, + previous_public_key, + previous_network, + previous_funded, + previous_secret_key, + } + }, + ) +} + +fn feature_flags() -> impl Strategy { + ( + any::(), + 0u32..3650, + proptest::collection::hash_map("[a-z_]{1,12}", "[A-Za-z0-9_-]{0,16}", 0..4), + ) + .prop_map( + |(metrics_enabled, metrics_retention_days, default_attributes)| FeatureFlagsConfig { + metrics_enabled, + metrics_retention_days, + default_attributes, + }, + ) +} + +fn plugin_trust() -> impl Strategy { + proptest::collection::vec( + proptest::sample::select(vec![ + "https://github.com/Nanle-code/starforge-*".to_string(), + "https://plugins.example.com/releases/".to_string(), + "plugins.example.org".to_string(), + ]), + 0..3, + ) + .prop_map(|trusted_sources| PluginTrustConfig { trusted_sources }) +} + +/// A configuration that is internally consistent: every referenced network +/// exists, wallet names are unique, and every URL is an HTTP(S) endpoint. +fn valid_config() -> impl Strategy { + ( + proptest::collection::hash_map(network_name(), network_config(), 1..4), + proptest::collection::vec( + ( + wallet_name(), + public_key(), + proptest::option::of(prop_oneof![secret_key(), encrypted_bundle()]), + any::(), + proptest::collection::vec(rotation_record(), 0..2), + ), + 0..4, + ), + proptest::option::of(any::()), + proptest::option::of("[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"), + feature_flags(), + plugin_trust(), + ) + .prop_map( + |( + networks, + wallet_specs, + telemetry_enabled, + install_id, + feature_flags, + plugin_trust, + )| { + let network_names: Vec = networks.keys().cloned().collect(); + let active = network_names[0].clone(); + + let mut used_names = std::collections::HashSet::new(); + let mut wallets = Vec::new(); + for (i, (name, public_key, secret_key, funded, rotation_history)) in + wallet_specs.into_iter().enumerate() + { + // Force uniqueness rather than discarding the case: the + // duplicate-name path has its own dedicated test below. + let mut name = name; + while !used_names.insert(name.clone()) { + name = format!("{}-{}", name, i); + } + wallets.push(WalletEntry { + name, + public_key, + secret_key, + network: network_names[i % network_names.len()].clone(), + created_at: "2026-01-01T00:00:00Z".to_string(), + funded, + rotation_history, + }); + } + + Config { + version: "1".to_string(), + network: active, + telemetry_enabled, + install_id, + wallet_encryption: None, + networks, + plugin_trust, + feature_flags, + ai_telemetry: AiTelemetryConfig::default(), + wallets, + } + }, + ) +} + +/// An overlay whose networks and wallets never collide with `base`. +fn overlay_for(base: &Config) -> impl Strategy { + let existing_wallets: std::collections::HashSet = + base.wallets.iter().map(|w| w.name.clone()).collect(); + let network_names: Vec = base.networks.keys().cloned().collect(); + + ( + proptest::option::of(proptest::sample::select(network_names)), + proptest::option::of(any::()), + proptest::collection::hash_map(network_name(), network_config(), 0..3), + proptest::collection::vec((wallet_name(), public_key()), 0..3), + proptest::option::of(feature_flags()), + ) + .prop_map(move |(network, telemetry, networks, wallet_specs, flags)| { + let mut seen = existing_wallets.clone(); + let mut wallets = Vec::new(); + for (i, (name, public_key)) in wallet_specs.into_iter().enumerate() { + let mut name = format!("overlay-{}", name); + while !seen.insert(name.clone()) { + name = format!("{}-{}", name, i); + } + wallets.push(WalletEntry { + name, + public_key, + secret_key: None, + // Reserved network: always resolvable regardless of which + // networks the overlay happens to add. + network: "testnet".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + funded: false, + rotation_history: Vec::new(), + }); + } + ConfigOverlay { + network, + telemetry_enabled: telemetry, + wallet_encryption: None, + feature_flags: flags, + ai_telemetry: None, + plugin_trust: None, + networks, + wallets, + } + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Preservation properties (primary flow) +// ───────────────────────────────────────────────────────────────────────────── + +proptest! { + /// A generated configuration is one the validator accepts. If this fails, + /// every property below is testing the wrong thing. + #[test] + fn generated_configs_are_valid(cfg in valid_config()) { + prop_assert!(config::validate_config(&cfg).is_ok(), "generator produced an invalid config: {:?}", cfg); + } + + /// TOML serialization round trips exactly. + #[test] + fn toml_round_trip_preserves_every_value(cfg in valid_config()) { + let text = config::to_toml_string(&cfg).expect("serialize to TOML"); + let parsed = config::parse_config_str(&text).expect("parse TOML back"); + prop_assert_eq!(parsed, cfg); + } + + /// JSON serialization (the database and export format) round trips exactly. + #[test] + fn json_round_trip_preserves_every_value(cfg in valid_config()) { + let text = config::to_json_string(&cfg).expect("serialize to JSON"); + let parsed = config::parse_config_json(&text).expect("parse JSON back"); + prop_assert_eq!(parsed, cfg); + } + + /// Crossing formats does not lose anything either: TOML → value → JSON → + /// value must land on the same configuration. + #[test] + fn cross_format_round_trip_is_stable(cfg in valid_config()) { + let via_toml = config::parse_config_str(&config::to_toml_string(&cfg).unwrap()).unwrap(); + let via_json = config::parse_config_json(&config::to_json_string(&via_toml).unwrap()).unwrap(); + prop_assert_eq!(via_json, cfg); + } + + /// Serializing twice produces byte-identical TOML (no hidden state). + #[test] + fn serialization_is_deterministic(cfg in valid_config()) { + let a = config::to_toml_string(&cfg).unwrap(); + let b = config::to_toml_string(&cfg).unwrap(); + prop_assert_eq!(a, b); + } + + /// Merging an empty overlay is the identity. + #[test] + fn merging_an_empty_overlay_changes_nothing(cfg in valid_config()) { + let merged = config::merge_configs(cfg.clone(), ConfigOverlay::default()) + .expect("merging an empty overlay must succeed"); + prop_assert_eq!(merged, cfg); + } + + /// Merging is idempotent: applying the same overlay twice adds nothing the + /// first application did not already add. (The second application is + /// expected to fail on duplicate wallets, which is itself the guarantee — + /// so wallets are dropped before re-applying.) + #[test] + fn merging_is_idempotent( + (cfg, overlay) in valid_config().prop_flat_map(|c| { + let c2 = c.clone(); + (Just(c), overlay_for(&c2)) + }) + ) { + let once = config::merge_configs(cfg, overlay.clone()).expect("first merge"); + let without_wallets = ConfigOverlay { wallets: Vec::new(), ..overlay }; + let twice = config::merge_configs(once.clone(), without_wallets).expect("second merge"); + prop_assert_eq!(twice, once); + } + + /// The overlay wins for every scalar it sets, and the base is preserved for + /// every scalar it does not. + #[test] + fn overlay_takes_precedence_over_the_base( + (cfg, overlay) in valid_config().prop_flat_map(|c| { + let c2 = c.clone(); + (Just(c), overlay_for(&c2)) + }) + ) { + let base = cfg.clone(); + let merged = config::merge_configs(cfg, overlay.clone()).expect("merge"); + + match &overlay.network { + Some(n) => prop_assert_eq!(&merged.network, n), + None => prop_assert_eq!(&merged.network, &base.network), + } + match overlay.telemetry_enabled { + Some(t) => prop_assert_eq!(merged.telemetry_enabled, Some(t)), + None => prop_assert_eq!(merged.telemetry_enabled, base.telemetry_enabled), + } + + // The installation identity is never taken from an overlay. + prop_assert_eq!(&merged.version, &base.version); + prop_assert_eq!(&merged.install_id, &base.install_id); + + // Every overlay network is present with the overlay's value. + for (name, net) in &overlay.networks { + prop_assert_eq!(merged.networks.get(name), Some(net)); + } + // Base networks the overlay did not mention survive untouched. + for (name, net) in &base.networks { + if !overlay.networks.contains_key(name) { + prop_assert_eq!(merged.networks.get(name), Some(net)); + } + } + // Wallets are appended, never replaced. + prop_assert_eq!(merged.wallets.len(), base.wallets.len() + overlay.wallets.len()); + } + + /// A merged configuration is still a valid configuration that round trips. + #[test] + fn merged_configs_still_round_trip( + (cfg, overlay) in valid_config().prop_flat_map(|c| { + let c2 = c.clone(); + (Just(c), overlay_for(&c2)) + }) + ) { + let merged = config::merge_configs(cfg, overlay).expect("merge"); + prop_assert!(config::validate_config(&merged).is_ok()); + + let text = config::to_toml_string(&merged).unwrap(); + prop_assert_eq!(config::parse_config_str(&text).unwrap(), merged); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Rejection properties (malformed combinations) +// ───────────────────────────────────────────────────────────────────────────── + +proptest! { + /// An active network that is neither configured nor built in is rejected. + #[test] + fn unknown_active_network_is_rejected( + cfg in valid_config(), + stray in "[a-z][a-z0-9-]{3,15}", + ) { + prop_assume!(!cfg.networks.contains_key(&stray)); + prop_assume!(!config::is_reserved_network(&stray)); + + let broken = Config { network: stray.clone(), ..cfg }; + let err = config::validate_config(&broken).unwrap_err(); + prop_assert!(err.to_string().contains(&stray), "error should name the network: {}", err); + } + + /// Two wallets with the same name is a malformed combination even though + /// each entry is individually well formed. + #[test] + fn duplicate_wallet_names_are_rejected(cfg in valid_config(), key in public_key()) { + prop_assume!(!cfg.wallets.is_empty()); + + let mut broken = cfg.clone(); + let clone_of = broken.wallets[0].clone(); + broken.wallets.push(WalletEntry { public_key: key, ..clone_of }); + + let err = config::validate_config(&broken).unwrap_err(); + prop_assert!(err.to_string().contains("Duplicate wallet name"), "got: {}", err); + } + + /// A wallet pointing at a network that is not configured is rejected. + #[test] + fn wallet_on_an_unknown_network_is_rejected( + cfg in valid_config(), + key in public_key(), + stray in "[a-z][a-z0-9-]{3,15}", + ) { + prop_assume!(!cfg.networks.contains_key(&stray)); + prop_assume!(!config::is_reserved_network(&stray)); + + let mut broken = cfg; + broken.wallets.push(WalletEntry { + name: "orphan-wallet".to_string(), + public_key: key, + secret_key: None, + network: stray, + created_at: "2026-01-01T00:00:00Z".to_string(), + funded: false, + rotation_history: Vec::new(), + }); + prop_assert!(config::validate_config(&broken).is_err()); + } + + /// Endpoints must be HTTP(S); any other scheme is refused. + #[test] + fn non_http_endpoints_are_rejected( + cfg in valid_config(), + scheme in proptest::sample::select(vec!["ftp", "file", "ws", "javascript"]), + host in "[a-z]{3,10}\\.example\\.com", + ) { + let mut broken = cfg; + let name = broken.networks.keys().next().unwrap().clone(); + broken.networks.get_mut(&name).unwrap().horizon_url = format!("{}://{}", scheme, host); + + let err = config::validate_config(&broken).unwrap_err(); + prop_assert!(err.to_string().contains("horizon_url"), "got: {}", err); + } + + /// A malformed public key is rejected wherever it appears. + #[test] + fn malformed_public_keys_are_rejected(cfg in valid_config(), bad in "[a-z]{1,60}") { + let mut broken = cfg; + broken.wallets.push(WalletEntry { + name: "bad-key-wallet".to_string(), + public_key: bad, + secret_key: None, + network: broken.network.clone(), + created_at: "2026-01-01T00:00:00Z".to_string(), + funded: false, + rotation_history: Vec::new(), + }); + prop_assert!(config::validate_config(&broken).is_err()); + } + + /// An overlay whose wallet name collides with the base is rejected instead + /// of silently replacing stored key material. + #[test] + fn overlay_wallet_collision_is_rejected(cfg in valid_config()) { + prop_assume!(!cfg.wallets.is_empty()); + + let clash = cfg.wallets[0].clone(); + let overlay = ConfigOverlay { wallets: vec![clash], ..Default::default() }; + let err = config::merge_configs(cfg, overlay).unwrap_err(); + prop_assert!(err.to_string().contains("already exists"), "got: {}", err); + } + + /// An overlay that switches to an unconfigured network is rejected by the + /// merge, not deferred to a later save. + #[test] + fn overlay_cannot_select_an_unknown_network( + cfg in valid_config(), + stray in "[a-z][a-z0-9-]{3,15}", + ) { + prop_assume!(!cfg.networks.contains_key(&stray)); + prop_assume!(!config::is_reserved_network(&stray)); + + let overlay = ConfigOverlay { network: Some(stray), ..Default::default() }; + prop_assert!(config::merge_configs(cfg, overlay).is_err()); + } + + /// Unknown overlay keys are a hard error — a typo must not silently do + /// nothing. + #[test] + fn unknown_overlay_keys_are_rejected(key in "[a-z_]{3,15}") { + prop_assume!(![ + "network", "telemetry_enabled", "wallet_encryption", + "feature_flags", "ai_telemetry", "plugin_trust", "networks", "wallets", + ].contains(&key.as_str())); + + let doc = format!("{} = \"whatever\"\n", key); + prop_assert!(config::parse_overlay_str(&doc).is_err(), "accepted unknown key {}", key); + } + + /// Parsing arbitrary bytes never panics; it either succeeds or errors. + #[test] + fn parsing_arbitrary_text_never_panics(text in ".{0,400}") { + let _ = config::parse_config_str(&text); + let _ = config::parse_config_json(&text); + let _ = config::parse_overlay_str(&text); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Boundary cases (fixed inputs at the edges of the accepted range) +// ───────────────────────────────────────────────────────────────────────────── + +fn minimal_config() -> Config { + let mut networks = HashMap::new(); + networks.insert( + "testnet".to_string(), + NetworkConfig { + horizon_url: "https://horizon-testnet.stellar.org".to_string(), + soroban_rpc_url: None, + friendbot_url: None, + passphrase: None, + }, + ); + Config { + version: "1".to_string(), + network: "testnet".to_string(), + telemetry_enabled: None, + install_id: None, + wallet_encryption: None, + networks, + plugin_trust: PluginTrustConfig::default(), + feature_flags: FeatureFlagsConfig::default(), + ai_telemetry: AiTelemetryConfig::default(), + wallets: Vec::new(), + } +} + +#[test] +fn boundary_smallest_valid_config_round_trips() { + let cfg = minimal_config(); + assert!(config::validate_config(&cfg).is_ok()); + + let text = config::to_toml_string(&cfg).unwrap(); + assert_eq!(config::parse_config_str(&text).unwrap(), cfg); +} + +#[test] +fn boundary_all_optional_fields_absent_survive_json() { + let cfg = minimal_config(); + let text = config::to_json_string(&cfg).unwrap(); + let parsed = config::parse_config_json(&text).unwrap(); + + assert_eq!(parsed.telemetry_enabled, None); + assert_eq!(parsed.install_id, None); + assert_eq!(parsed.wallet_encryption, None); + assert_eq!(parsed, cfg); +} + +#[test] +fn boundary_missing_optional_keys_fall_back_to_defaults() { + // The smallest document the parser is required to accept. + let doc = r#" +network = "testnet" +wallets = [] +"#; + let cfg = config::parse_config_str(doc).unwrap(); + assert_eq!(cfg.version, "1"); + assert_eq!(cfg.network, "testnet"); + assert!(cfg.networks.is_empty()); + assert_eq!(cfg.feature_flags, FeatureFlagsConfig::default()); + assert_eq!(cfg.plugin_trust, PluginTrustConfig::default()); +} + +#[test] +fn boundary_empty_version_is_rejected() { + let cfg = Config { + version: String::new(), + ..minimal_config() + }; + assert!(config::validate_config(&cfg).is_err()); +} + +#[test] +fn boundary_empty_networks_map_is_rejected() { + let cfg = Config { + networks: HashMap::new(), + ..minimal_config() + }; + assert!(config::validate_config(&cfg).is_err()); +} + +#[test] +fn boundary_whitespace_only_active_network_is_rejected() { + let cfg = Config { + network: " ".to_string(), + ..minimal_config() + }; + assert!(config::validate_config(&cfg).is_err()); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Failure cases (explicit, deterministic) +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn failure_truncated_toml_is_rejected() { + assert!(config::parse_config_str("network = \"testnet").is_err()); + assert!(config::parse_config_str("[networks.testnet").is_err()); +} + +#[test] +fn failure_wrong_type_for_a_known_key_is_rejected() { + assert!(config::parse_config_str("network = 42\nwallets = []\n").is_err()); + assert!(config::parse_config_json(r#"{"network": true, "wallets": []}"#).is_err()); +} + +#[test] +fn failure_overlay_typo_is_reported_with_the_key() { + let err = config::parse_overlay_str("netwrok = \"mainnet\"\n").unwrap_err(); + assert!( + format!("{:#}", err).contains("netwrok"), + "error should name the offending key: {:#}", + err + ); +} + +#[test] +fn failure_validation_does_not_depend_on_the_host_machine() { + // `validate_config` must be pure: no disk, no database, no environment. + // Running it repeatedly on the same value must give the same answer. + let cfg = Config { + network: "not-configured-anywhere".to_string(), + ..minimal_config() + }; + let first = config::validate_config(&cfg).is_err(); + for _ in 0..10 { + assert_eq!(config::validate_config(&cfg).is_err(), first); + } + assert!(first); +} diff --git a/tests/correlation_logging.rs b/tests/correlation_logging.rs new file mode 100644 index 00000000..d13a7a0a --- /dev/null +++ b/tests/correlation_logging.rs @@ -0,0 +1,259 @@ +//! End-to-end checks that structured logs carry a correlation ID (issue #694). +//! +//! The unit tests in `utils::correlation` cover ID validation in isolation. +//! These tests drive a real `tracing` subscriber and inspect the emitted JSON, +//! which is what a log aggregator actually consumes. +//! +//! Run with: +//! cargo test --test correlation_logging + +#![allow(dead_code)] + +use starforge::utils::correlation::{self, CorrelationId, CorrelationIdError}; +use std::io; +use std::sync::{Arc, Mutex}; +use tracing_subscriber::fmt::MakeWriter; + +/// A `MakeWriter` that appends everything into a shared buffer. +#[derive(Clone, Default)] +struct CaptureWriter(Arc>>); + +impl CaptureWriter { + fn contents(&self) -> String { + String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned() + } +} + +impl io::Write for CaptureWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl<'a> MakeWriter<'a> for CaptureWriter { + type Writer = CaptureWriter; + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } +} + +/// Run `body` under a JSON subscriber and return the captured log lines. +fn capture_json_logs(body: impl FnOnce()) -> Vec { + let writer = CaptureWriter::default(); + let subscriber = tracing_subscriber::fmt() + .json() + .with_current_span(true) + .with_span_list(true) + .with_max_level(tracing::Level::TRACE) + .with_writer(writer.clone()) + .finish(); + + tracing::subscriber::with_default(subscriber, body); + + writer + .contents() + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).expect("each log line must be valid JSON")) + .collect() +} + +/// Every span in a record's span list, plus the record's own fields, flattened +/// into a single string for substring assertions. +fn record_text(record: &serde_json::Value) -> String { + record.to_string() +} + +fn correlation_ids_in(record: &serde_json::Value) -> Vec { + record["spans"] + .as_array() + .map(|spans| { + spans + .iter() + .filter_map(|s| s["correlation_id"].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +// ── Primary flow ───────────────────────────────────────────────────────────── + +#[test] +fn every_nested_event_carries_the_same_correlation_id() { + // `init` is process-global and first-call-wins, so read back whatever ID is + // installed rather than assuming this test installed it. + let installed = correlation::init(CorrelationId::generate()).clone(); + + let records = capture_json_logs(|| { + let command = correlation::command_span("deploy"); + let _command = command.enter(); + tracing::info!("command started"); + + { + let step = correlation::deploy_step_span("upload-wasm", 1, 3); + let _step = step.enter(); + tracing::info!("uploading"); + + let retry = correlation::retry_span("horizon-submit", 2, 5); + let _retry = retry.enter(); + tracing::debug!("retrying after timeout"); + + let net = correlation::network_span("POST", "https://soroban-testnet.stellar.org/"); + let _net = net.enter(); + tracing::debug!("request sent"); + } + + let plugin = correlation::plugin_span("starforge-lint", "run"); + let _plugin = plugin.enter(); + tracing::info!("plugin invoked"); + }); + + assert!( + records.len() >= 5, + "expected one record per event, got {}", + records.len() + ); + + for record in &records { + let ids = correlation_ids_in(record); + assert!( + !ids.is_empty(), + "record has no correlation id: {}", + record_text(record) + ); + for id in ids { + assert_eq!( + id, + installed.as_str(), + "a nested span used a different correlation id: {}", + record_text(record) + ); + } + } +} + +#[test] +fn span_kinds_identify_retries_requests_plugins_and_steps() { + correlation::init(CorrelationId::generate()); + + let records = capture_json_logs(|| { + let command = correlation::command_span("deploy"); + let _command = command.enter(); + + for (span, message) in [ + (correlation::retry_span("submit", 1, 3), "retry"), + ( + correlation::network_span("GET", "https://horizon.stellar.org/accounts"), + "network", + ), + (correlation::plugin_span("starforge-lint", "run"), "plugin"), + (correlation::deploy_step_span("verify", 3, 3), "step"), + ] { + let _entered = span.enter(); + tracing::info!(stage = message, "work"); + } + }); + + let text = records + .iter() + .map(record_text) + .collect::>() + .join("\n"); + + for kind in [ + "retry", + "network_request", + "plugin_call", + "deploy_step", + "command", + ] { + assert!( + text.contains(kind), + "no span of kind `{}` in the log:\n{}", + kind, + text + ); + } +} + +// ── Boundary case ──────────────────────────────────────────────────────────── + +#[test] +fn an_event_outside_the_command_span_still_logs_cleanly() { + // Nothing should panic, and the record simply has no span list. + let records = capture_json_logs(|| { + tracing::warn!("emitted before any command span was entered"); + }); + + assert_eq!(records.len(), 1); + assert!(correlation_ids_in(&records[0]).is_empty()); + assert!(record_text(&records[0]).contains("emitted before any command span")); +} + +// ── Failure cases ──────────────────────────────────────────────────────────── + +#[test] +fn secrets_never_reach_the_log_through_span_attributes() { + correlation::init(CorrelationId::generate()); + + let secret = format!("S{}", "A".repeat(55)); + let url = "https://ci:s3cr3t-token@rpc.example.com/soroban?apiKey=AKIAIOSFODNN7EXAMPLE"; + + let records = capture_json_logs(|| { + let command = correlation::command_span(&secret); + let _command = command.enter(); + + let net = correlation::network_span("POST", url); + let _net = net.enter(); + tracing::info!("request sent"); + }); + + let text = records + .iter() + .map(record_text) + .collect::>() + .join("\n"); + + assert!( + !text.contains(&secret), + "a secret key leaked into the log:\n{}", + text + ); + assert!( + !text.contains("s3cr3t-token"), + "credentials leaked:\n{}", + text + ); + assert!( + !text.contains("AKIAIOSFODNN7EXAMPLE"), + "query secret leaked:\n{}", + text + ); + assert!( + text.contains("rpc.example.com"), + "host should survive:\n{}", + text + ); +} + +#[test] +fn an_invalid_correlation_id_is_rejected_before_it_can_be_logged() { + assert_eq!( + CorrelationId::parse("bad id").unwrap_err(), + CorrelationIdError::InvalidCharacter(' ') + ); + assert_eq!( + CorrelationId::parse("short").unwrap_err(), + CorrelationIdError::TooShort(5) + ); + + let secret = format!("S{}", "B".repeat(55)); + assert_eq!( + CorrelationId::parse(&secret).unwrap_err(), + CorrelationIdError::LooksLikeSecret + ); +} diff --git a/tests/fixtures/soroban_rpc/simulate_success.json b/tests/fixtures/soroban_rpc/simulate_success.json index 7fa80286..4b9fb8e7 100644 --- a/tests/fixtures/soroban_rpc/simulate_success.json +++ b/tests/fixtures/soroban_rpc/simulate_success.json @@ -3,10 +3,12 @@ "id": 1, "result": { "latestLedger": 12345, + "minResourceFee": "58181", "cost": { "cpuInsns": 150000, "memBytes": 2048 }, + "transactionData": "AAAAAAAAAAIAAAAHAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEAAAAHAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAAAABAAAABwkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJABNxRAAAIAAAAAQAAAAAAAAA40U=", "results": [ { "xdr": "AAAABQ==", diff --git a/tests/wallet_import_property_tests.rs b/tests/wallet_import_property_tests.rs new file mode 100644 index 00000000..b8af16db --- /dev/null +++ b/tests/wallet_import_property_tests.rs @@ -0,0 +1,321 @@ +//! Generated-input tests for the wallet import and backup parsers (issue #697). +//! +//! The `fuzz/fuzz_targets/fuzz_wallet_*` harnesses explore these same functions +//! far more deeply, but `cargo fuzz` needs a nightly toolchain and runs for +//! minutes. These proptest cases run on stable in the normal `cargo test` +//! sweep, so the invariants the fuzzers assert are also checked on every PR. +//! +//! Run with: +//! cargo test --test wallet_import_property_tests +//! +//! Deeper coverage: +//! PROPTEST_CASES=10000 cargo test --test wallet_import_property_tests + +#![allow(dead_code)] + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use proptest::prelude::*; +use starforge::utils::wallet_import::{ + classify_payload, parse_encrypted_envelope, parse_wallet_backup, PayloadKind, + WalletImportError, GCM_TAG_LEN, MAX_BACKUP_BYTES, MAX_WALLETS_PER_BACKUP, MAX_WALLET_NAME_LEN, + NONCE_LEN, SALT_LEN, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +const STELLAR_CHARSET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +fn stellar_chars(len: usize) -> impl Strategy { + proptest::collection::vec(proptest::sample::select(STELLAR_CHARSET.as_bytes()), len) + .prop_map(|v| String::from_utf8(v).unwrap()) +} + +fn public_key() -> impl Strategy { + stellar_chars(55).prop_map(|s| format!("G{}", s)) +} + +fn secret_key() -> impl Strategy { + stellar_chars(55).prop_map(|s| format!("S{}", s)) +} + +fn wallet_name() -> impl Strategy { + "[A-Za-z0-9_-]{1,32}" +} + +fn backup_document(entries: &[String]) -> String { + format!( + r#"{{"version":"1","exported_at":"2026-07-29T00:00:00Z","wallets":[{}]}}"#, + entries.join(",") + ) +} + +fn entry_json(name: &str, public_key: &str, secret_key: Option<&str>, network: &str) -> String { + let secret = match secret_key { + Some(s) => format!("\"{}\"", s), + None => "null".to_string(), + }; + format!( + r#"{{"name":"{}","public_key":"{}","secret_key":{},"network":"{}","created_at":"2026-07-29T00:00:00Z","funded":false}}"#, + name, public_key, secret, network + ) +} + +fn envelope(salt_len: usize, nonce_len: usize, ct_len: usize) -> String { + format!( + "{}:{}:{}", + BASE64.encode(vec![1u8; salt_len]), + BASE64.encode(vec![2u8; nonce_len]), + BASE64.encode(vec![3u8; ct_len]) + ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Primary flow +// ───────────────────────────────────────────────────────────────────────────── + +proptest! { + /// Any generated well-formed backup parses, and the parsed values are the + /// ones that went in. + #[test] + fn well_formed_backups_parse_and_preserve_their_values( + entries in proptest::collection::vec( + (wallet_name(), public_key(), proptest::option::of(secret_key())), + 1..6, + ) + ) { + // Names must be unique for the document to be valid at all. + let mut seen = std::collections::HashSet::new(); + let entries: Vec<_> = entries + .into_iter() + .filter(|(name, _, _)| seen.insert(name.clone())) + .collect(); + + let doc = backup_document( + &entries + .iter() + .map(|(name, key, secret)| entry_json(name, key, secret.as_deref(), "testnet")) + .collect::>(), + ); + + let parsed = parse_wallet_backup(&doc).expect("well-formed backup must parse"); + prop_assert_eq!(parsed.backup.wallets.len(), entries.len()); + for (wallet, (name, key, secret)) in parsed.backup.wallets.iter().zip(&entries) { + prop_assert_eq!(&wallet.name, name); + prop_assert_eq!(&wallet.public_key, key); + prop_assert_eq!(&wallet.secret_key, secret); + } + prop_assert!(parsed.warnings.is_empty()); + } + + /// Any structurally valid envelope parses back to the exact field lengths. + #[test] + fn well_formed_envelopes_parse(ct_len in GCM_TAG_LEN..512usize) { + let env = parse_encrypted_envelope(&envelope(SALT_LEN, NONCE_LEN, ct_len)) + .expect("structurally valid envelope must parse"); + prop_assert_eq!(env.salt.len(), SALT_LEN); + prop_assert_eq!(env.nonce.len(), NONCE_LEN); + prop_assert_eq!(env.ciphertext.len(), ct_len); + } + + /// Classification is deterministic and never mistakes JSON for a bundle. + #[test] + fn classification_is_deterministic_and_never_misreads_json(text in ".{0,300}") { + let first = classify_payload(&text); + prop_assert_eq!(first, classify_payload(&text)); + + let trimmed = text.trim(); + if trimmed.starts_with('{') || trimmed.starts_with('[') { + prop_assert_eq!(first, PayloadKind::Plaintext); + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Boundary cases +// ───────────────────────────────────────────────────────────────────────────── + +proptest! { + /// Ciphertexts shorter than one GCM tag are always rejected; anything at or + /// above the tag length is always accepted. + #[test] + fn the_ciphertext_length_boundary_is_exact(len in 0usize..64) { + let result = parse_encrypted_envelope(&envelope(SALT_LEN, NONCE_LEN, len)); + if len < GCM_TAG_LEN { + prop_assert!( + matches!(result, Err(WalletImportError::TruncatedCiphertext { .. })), + "accepted a {}-byte ciphertext", len + ); + } else { + prop_assert!(result.is_ok(), "rejected a {}-byte ciphertext", len); + } + } + + /// Salt and nonce lengths other than the expected ones are rejected. + #[test] + fn salt_and_nonce_lengths_are_enforced(len in 0usize..40) { + let salt_result = parse_encrypted_envelope(&envelope(len, NONCE_LEN, 32)); + prop_assert_eq!(salt_result.is_ok(), len == SALT_LEN); + + let nonce_result = parse_encrypted_envelope(&envelope(SALT_LEN, len, 32)); + prop_assert_eq!(nonce_result.is_ok(), len == NONCE_LEN); + } + + /// Wallet names are accepted up to the character limit and rejected beyond. + #[test] + fn the_wallet_name_length_boundary_is_exact( + len in 1usize..(MAX_WALLET_NAME_LEN + 8), + key in public_key(), + ) { + let name = "a".repeat(len); + let doc = backup_document(&[entry_json(&name, &key, None, "testnet")]); + let result = parse_wallet_backup(&doc); + + if len <= MAX_WALLET_NAME_LEN { + prop_assert!(result.is_ok(), "rejected a {}-character name", len); + } else { + prop_assert!( + matches!(result, Err(WalletImportError::DeceptiveWalletName { .. })), + "accepted a {}-character name", len + ); + } + } +} + +#[test] +fn boundary_oversized_input_is_rejected_by_size_not_by_content() { + // Valid JSON, just too big: the size gate must fire before the parser. + let padding = "a".repeat(MAX_BACKUP_BYTES); + let doc = format!( + r#"{{"version":"1","exported_at":"{}","wallets":[]}}"#, + padding + ); + + assert!(matches!( + parse_wallet_backup(&doc), + Err(WalletImportError::TooLarge { .. }) + )); +} + +#[test] +fn boundary_exactly_one_wallet_is_the_minimum_accepted() { + let key = format!("G{}", "A".repeat(55)); + assert!(parse_wallet_backup(&backup_document(&[])).is_err()); + assert!(parse_wallet_backup(&backup_document(&[entry_json( + "solo", &key, None, "testnet" + )])) + .is_ok()); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Failure cases +// ───────────────────────────────────────────────────────────────────────────── + +proptest! { + /// No input, however hostile, may panic either parser. + #[test] + fn parsers_never_panic(text in ".{0,600}") { + let _ = parse_wallet_backup(&text); + let _ = parse_encrypted_envelope(&text); + let _ = classify_payload(&text); + } + + /// Arbitrary bytes reinterpreted as UTF-8 must not panic either. + #[test] + fn parsers_never_panic_on_lossy_bytes(bytes in proptest::collection::vec(any::(), 0..600)) { + let text = String::from_utf8_lossy(&bytes); + let _ = parse_wallet_backup(&text); + let _ = parse_encrypted_envelope(&text); + let _ = classify_payload(&text); + } + + /// A public key that is not a well-formed StrKey is always rejected. + #[test] + fn malformed_public_keys_are_always_rejected(bad in "[a-zA-Z0-9]{0,60}") { + prop_assume!(!(bad.len() == 55 && bad.chars().all(|c| matches!(c, 'A'..='Z' | '2'..='7')))); + + let doc = backup_document(&[entry_json("w", &format!("G{}", bad), None, "testnet")]); + prop_assert!( + matches!(parse_wallet_backup(&doc), Err(WalletImportError::InvalidEntry { .. })), + "accepted a malformed public key of length {}", bad.len() + 1 + ); + } + + /// A secret key that is neither a StrKey nor a valid bundle is rejected. + #[test] + fn malformed_secret_keys_are_always_rejected(bad in "[A-Z2-7]{0,54}", key in public_key()) { + let secret = format!("S{}", bad); + prop_assume!(secret.len() != 56); + + let doc = backup_document(&[entry_json("w", &key, Some(&secret), "testnet")]); + prop_assert!( + matches!( + parse_wallet_backup(&doc), + Err(WalletImportError::InvalidEntry { .. }) + ), + "accepted a malformed secret key of length {}", + secret.len() + ); + } + + /// Invisible and direction-changing characters in a name are always + /// rejected, whichever position they occupy. + #[test] + fn deceptive_names_are_always_rejected( + prefix in "[a-z]{0,8}", + suffix in "[a-z]{0,8}", + bad in proptest::sample::select(vec![ + '\u{200B}', '\u{200E}', '\u{202A}', '\u{202E}', '\u{2066}', '\u{FEFF}', '\u{00AD}', + ]), + key in public_key(), + ) { + let name = format!("{}{}{}", prefix, bad, suffix); + let doc = backup_document(&[entry_json(&name, &key, None, "testnet")]); + + prop_assert!( + matches!(parse_wallet_backup(&doc), Err(WalletImportError::DeceptiveWalletName { .. })), + "accepted a name containing U+{:04X}", bad as u32 + ); + } + + /// A rejection must never quote the secret key back at the user, because + /// error text lands in terminals, CI logs, and bug reports. + #[test] + fn errors_never_echo_secret_material(secret in secret_key()) { + // Well-formed secret, malformed public key: the entry is rejected and + // the message must mention the wallet, not the key. + let doc = backup_document(&[entry_json("w", "GBAD", Some(&secret), "testnet")]); + let err = parse_wallet_backup(&doc).unwrap_err().to_string(); + + prop_assert!(!err.contains(&secret), "secret leaked into: {}", err); + } +} + +#[test] +fn failure_duplicate_names_are_rejected_even_when_entries_differ() { + let a = format!("G{}", "A".repeat(55)); + let b = format!("G{}", "B".repeat(55)); + let doc = backup_document(&[ + entry_json("same", &a, None, "testnet"), + entry_json("same", &b, None, "mainnet"), + ]); + + assert_eq!( + parse_wallet_backup(&doc).unwrap_err(), + WalletImportError::DuplicateWallet("same".to_string()) + ); +} + +#[test] +fn failure_too_many_wallets_is_rejected() { + let key = format!("G{}", "A".repeat(55)); + let entries: Vec = (0..=MAX_WALLETS_PER_BACKUP) + .map(|i| entry_json(&format!("w{}", i), &key, None, "testnet")) + .collect(); + + assert!(matches!( + parse_wallet_backup(&backup_document(&entries)), + Err(WalletImportError::TooManyWallets { .. }) + )); +}