From 7d8c77547f04419af3ce83b42697cda2693cddb9 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 16:53:23 -0700 Subject: [PATCH 01/20] Scaffold stellar contract verify with metadata extraction. --- FULL_HELP_DOCS.md | 25 + .../src/commands/contract/build/verifiable.rs | 2 +- cmd/soroban-cli/src/commands/contract/mod.rs | 10 + .../src/commands/contract/verify.rs | 442 ++++++++++++++++++ 4 files changed, 478 insertions(+), 1 deletion(-) create mode 100644 cmd/soroban-cli/src/commands/contract/verify.rs diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index dd3d73212..fa897c674 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -98,6 +98,7 @@ Tools for smart contract developers - `optimize` — ⚠️ Deprecated, use `build --optimize`. Optimize a WASM file - `read` — Print the current value of a contract-data ledger entry - `restore` — Restore an evicted value for a contract-data legder entry +- `verify` — Verify that a contract's WASM reproduces from the build metadata it records, per SEP-58. Either pass a contract id/alias via `--id` (the WASM is fetched from the network) or a local file via `--wasm` ## `stellar contract asset` @@ -1151,6 +1152,30 @@ If no keys are specificed the contract itself is restored. - `--inclusion-fee ` — Maximum fee amount for transaction inclusion, in stroops. 1 stroop = 0.0000001 xlm. Defaults to 100 if no arg, env, or config value is provided - `--build-only` — Build the transaction and only write the base64 xdr to stdout +## `stellar contract verify` + +Verify that a contract's WASM reproduces from the build metadata it records, per SEP-58. Either pass a contract id/alias via `--id` (the WASM is fetched from the network) or a local file via `--wasm` + +**Usage:** `stellar contract verify [OPTIONS]` + +###### **Global Options:** + +- `--config-dir ` — Location of config directory. By default, it uses `$XDG_CONFIG_HOME/stellar` if set, falling back to `~/.config/stellar` otherwise. Contains configuration files, aliases, and other persistent settings + +###### **Options:** + +- `--id ` — Contract id or alias to fetch the WASM from the network +- `--wasm ` — Local WASM file to verify, instead of fetching from the network +- `--tarball-url ` — Local tarball file or http(s) URL to use as the source when the WASM's recorded SEP-58 metadata has only `tarball_sha256` (no `tarball_url`). Accepts http(s) URLs or local file paths +- `--trust` — Bypass interactive confirmation when the WASM's bldimg is not in the default trust list, or when the source is a tarball (tarballs are never default-trusted) + +###### **RPC Options:** + +- `--rpc-url ` — RPC server endpoint +- `--rpc-header ` — RPC Header(s) to include in requests to the RPC provider, example: "X-API-Key: abc123". Multiple headers can be added by passing the option multiple times +- `--network-passphrase ` — Network passphrase to sign the transaction sent to the rpc server +- `-n`, `--network ` — Name of network to use from config + ## `stellar doctor` Diagnose and troubleshoot CLI and network issues diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 1c6c15ba0..1f4402c4a 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -358,7 +358,7 @@ fn enforce_clean_tree(source_root: &Path) -> Result<(), Error> { Ok(()) } -fn bldimg_regex() -> Regex { +pub(crate) fn bldimg_regex() -> Regex { Regex::new(r"^(?:localhost(?::\d+)?|[^\s@/]*[.:][^\s@/]*)/[^\s@]+@sha256:[0-9a-f]{64}$") .unwrap() } diff --git a/cmd/soroban-cli/src/commands/contract/mod.rs b/cmd/soroban-cli/src/commands/contract/mod.rs index ee140be93..b7fcb58bb 100644 --- a/cmd/soroban-cli/src/commands/contract/mod.rs +++ b/cmd/soroban-cli/src/commands/contract/mod.rs @@ -17,6 +17,7 @@ pub mod read; pub mod restore; pub mod spec_verify; pub mod upload; +pub mod verify; use crate::{commands::global, print::Print, utils::deprecate_message}; @@ -104,6 +105,11 @@ pub enum Cmd { // run as part of `contract build` so for a general user this is not needed. #[command(name = "spec-verify", hide = true)] SpecVerify(spec_verify::Cmd), + + /// Verify that a contract's WASM reproduces from the build metadata it + /// records, per SEP-58. Either pass a contract id/alias via `--id` (the + /// WASM is fetched from the network) or a local file via `--wasm`. + Verify(verify::Cmd), } #[derive(thiserror::Error, Debug)] @@ -161,6 +167,9 @@ pub enum Error { #[error(transparent)] SpecVerify(#[from] spec_verify::Error), + + #[error(transparent)] + Verify(#[from] verify::Error), } impl Cmd { @@ -210,6 +219,7 @@ impl Cmd { Cmd::Read(read) => read.run().await?, Cmd::Restore(restore) => restore.run(global_args).await?, Cmd::SpecVerify(spec_verify) => spec_verify.run(global_args)?, + Cmd::Verify(verify) => verify.run(global_args).await?, } Ok(()) } diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs new file mode 100644 index 000000000..2ed91e7c8 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -0,0 +1,442 @@ +use std::path::PathBuf; + +use clap::Parser; +use soroban_spec_tools::contract::Spec; +use stellar_xdr::curr::{ScMetaEntry, ScMetaV0}; + +use crate::{ + commands::{ + contract::build::verifiable::{ + bldimg_regex, source_repo_regex, source_rev_regex, tarball_sha256_regex, + tarball_url_regex, + }, + global, + }, + config::{self, locator, network}, + print::Print, + wasm, +}; + +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + /// Contract id or alias to fetch the WASM from the network. + #[arg(long = "id", env = "STELLAR_CONTRACT_ID", conflicts_with = "wasm")] + pub contract_id: Option, + + /// Local WASM file to verify, instead of fetching from the network. + #[arg(long)] + pub wasm: Option, + + /// Local tarball file or http(s) URL to use as the source when the WASM's + /// recorded SEP-58 metadata has only `tarball_sha256` (no `tarball_url`). + /// Accepts http(s) URLs or local file paths. + #[arg(long)] + pub tarball_url: Option, + + /// Bypass interactive confirmation when the WASM's bldimg is not in the + /// default trust list, or when the source is a tarball (tarballs are + /// never default-trusted). + #[arg(long)] + pub trust: bool, + + #[command(flatten)] + pub locator: locator::Args, + + #[command(flatten)] + pub network: network::Args, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("must pass exactly one of --id or --wasm")] + MissingInput, + + #[error("reading wasm {0}: {1}")] + ReadWasm(PathBuf, std::io::Error), + + #[error(transparent)] + Network(#[from] network::Error), + + #[error(transparent)] + Locator(#[from] locator::Error), + + #[error(transparent)] + Wasm(#[from] wasm::Error), + + #[error(transparent)] + SpecTools(#[from] soroban_spec_tools::contract::Error), + + #[error("the WASM has no contractmetav0 custom section")] + NoMeta, + + #[error("the WASM's contractmetav0 does not record a `bldimg` entry; cannot verify")] + MissingBldimg, + + #[error("the WASM's contractmetav0 does not record any SEP-58 source-identification entry (source_repo+source_rev, tarball_url, or tarball_sha256); cannot verify")] + MissingSourceId, + + #[error( + "the WASM's `{field}` value {value:?} does not match the SEP-58 format regex `{regex}`" + )] + MetaFormat { + field: &'static str, + value: String, + regex: &'static str, + }, + + #[error("the WASM records `source_rev` but not `source_repo`; SEP-58 requires both together")] + SourceRevWithoutRepo, +} + +/// SEP-58 metadata extracted from a contract's `contractmetav0` section. +/// +/// `cliver` is intentionally not captured: the rebuild container re-injects it, +/// so verify's job is to ensure the rebuild's cliver matches the original's +/// (which it will when `bldimg` resolves to the same container). +#[derive(Debug, Clone)] +pub struct ExtractedMetadata { + pub bldimg: String, + pub source_repo: Option, + pub source_rev: Option, + pub tarball_url: Option, + pub tarball_sha256: Option, + pub bldopts: Vec, +} + +impl Cmd { + pub async fn run(&self, _global_args: &global::Args) -> Result<(), Error> { + let print = Print::new(false); + + let wasm_bytes = self.fetch_wasm().await?; + let meta = extract_metadata(&wasm_bytes)?; + + print.infoln(format!("bldimg: {}", meta.bldimg)); + if let Some(v) = &meta.source_repo { + print.infoln(format!("source_repo: {v}")); + } + if let Some(v) = &meta.source_rev { + print.infoln(format!("source_rev: {v}")); + } + if let Some(v) = &meta.tarball_url { + print.infoln(format!("tarball_url: {v}")); + } + if let Some(v) = &meta.tarball_sha256 { + print.infoln(format!("tarball_sha256: {v}")); + } + if !meta.bldopts.is_empty() { + print.infoln(format!("bldopt entries ({}):", meta.bldopts.len())); + for o in &meta.bldopts { + print.blankln(format!(" • {o}")); + } + } + + Ok(()) + } + + async fn fetch_wasm(&self) -> Result, Error> { + match (&self.contract_id, &self.wasm) { + (Some(id), None) => { + let network = self.network.get(&self.locator)?; + let resolved = + id.resolve_contract_id(&self.locator, &network.network_passphrase)?; + Ok(wasm::fetch_from_contract(&resolved, &network).await?) + } + (None, Some(path)) => std::fs::read(path).map_err(|e| Error::ReadWasm(path.clone(), e)), + _ => Err(Error::MissingInput), + } + } +} + +/// Walk the WASM's `contractmetav0` entries and pull out the SEP-58 fields we +/// need to drive a rebuild. Errors when `bldimg` is absent or when no source +/// identification is recorded, since neither has a sensible default. +pub fn extract_metadata(wasm: &[u8]) -> Result { + let spec = Spec::new(wasm)?; + if spec.meta.is_empty() { + return Err(Error::NoMeta); + } + + let mut bldimg: Option = None; + let mut source_repo: Option = None; + let mut source_rev: Option = None; + let mut tarball_url: Option = None; + let mut tarball_sha256: Option = None; + let mut bldopts: Vec = Vec::new(); + + for entry in &spec.meta { + let ScMetaEntry::ScMetaV0(ScMetaV0 { key, val }) = entry; + let k = key.to_string(); + let v = val.to_string(); + match k.as_str() { + "bldimg" => bldimg = Some(v), + "source_repo" => source_repo = Some(v), + "source_rev" => source_rev = Some(v), + "tarball_url" => tarball_url = Some(v), + "tarball_sha256" => tarball_sha256 = Some(v), + "bldopt" => bldopts.push(v), + _ => {} // cliver and any user --meta are intentionally ignored + } + } + + let bldimg = bldimg.ok_or(Error::MissingBldimg)?; + if !bldimg_regex().is_match(&bldimg) { + return Err(Error::MetaFormat { + field: "bldimg", + value: bldimg, + regex: BLDIMG_REGEX_STR, + }); + } + + if let Some(v) = &source_rev { + if !source_rev_regex().is_match(v) { + return Err(Error::MetaFormat { + field: "source_rev", + value: v.clone(), + regex: SOURCE_REV_REGEX_STR, + }); + } + } + if let Some(v) = &source_repo { + if !source_repo_regex().is_match(v) { + return Err(Error::MetaFormat { + field: "source_repo", + value: v.clone(), + regex: SOURCE_REPO_REGEX_STR, + }); + } + } + if let Some(v) = &tarball_url { + if !tarball_url_regex().is_match(v) { + return Err(Error::MetaFormat { + field: "tarball_url", + value: v.clone(), + regex: TARBALL_URL_REGEX_STR, + }); + } + } + if let Some(v) = &tarball_sha256 { + if !tarball_sha256_regex().is_match(v) { + return Err(Error::MetaFormat { + field: "tarball_sha256", + value: v.clone(), + regex: TARBALL_SHA256_REGEX_STR, + }); + } + } + + // SEP-58 lists `source_repo+source_rev` as a conformant combination. We + // refuse `source_rev` without `source_repo` here so the user sees a + // pointed error rather than a downstream "can't clone repo" surprise. + if source_rev.is_some() && source_repo.is_none() { + return Err(Error::SourceRevWithoutRepo); + } + + if source_repo.is_none() + && source_rev.is_none() + && tarball_url.is_none() + && tarball_sha256.is_none() + { + return Err(Error::MissingSourceId); + } + + Ok(ExtractedMetadata { + bldimg, + source_repo, + source_rev, + tarball_url, + tarball_sha256, + bldopts, + }) +} + +// These mirror the regex strings used in verifiable.rs. They're kept here only +// so `Error::MetaFormat` can render the regex back to the user as part of the +// error message. The actual matching uses the helpers from verifiable.rs. +const BLDIMG_REGEX_STR: &str = + r"^(?:localhost(?::\d+)?|[^\s@/]*[.:][^\s@/]*)/[^\s@]+@sha256:[0-9a-f]{64}$"; +const SOURCE_REV_REGEX_STR: &str = r"^[0-9a-f]{40}$"; +const SOURCE_REPO_REGEX_STR: &str = r"^(https?://\S+|github:[^/\s]+/[^/\s]+)$"; +const TARBALL_URL_REGEX_STR: &str = r"^https?://\S+$"; +const TARBALL_SHA256_REGEX_STR: &str = r"^[0-9a-f]{64}$"; + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + use stellar_xdr::curr::{Limited, Limits, ScMetaEntry, ScMetaV0, WriteXdr}; + + fn make_wasm_with_meta(entries: &[(&str, &str)]) -> Vec { + let xdr = encode_meta(entries); + let mut wasm = empty_wasm_module(); + wasm_gen::write_custom_section(&mut wasm, "contractmetav0", &xdr); + wasm + } + + fn empty_wasm_module() -> Vec { + // Minimal valid WASM: magic + version, no sections. + vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00] + } + + fn encode_meta(entries: &[(&str, &str)]) -> Vec { + let mut buf = Vec::new(); + let mut writer = Limited::new(Cursor::new(&mut buf), Limits::none()); + for (k, v) in entries { + ScMetaEntry::ScMetaV0(ScMetaV0 { + key: (*k).to_string().try_into().unwrap(), + val: (*v).to_string().try_into().unwrap(), + }) + .write_xdr(&mut writer) + .unwrap(); + } + buf + } + + fn good_bldimg() -> String { + format!("docker.io/stellar/stellar-cli@sha256:{}", "a".repeat(64)) + } + + #[test] + fn extract_metadata_happy_path_git_source() { + let wasm = make_wasm_with_meta(&[ + ("bldimg", &good_bldimg()), + ("source_repo", "https://github.com/foo/bar"), + ("source_rev", &"b".repeat(40)), + ("bldopt", "--locked"), + ("bldopt", "--meta=home_domain=fnando.com"), + ("home_domain", "fnando.com"), + ("cliver", "26.0.0#abcdef"), + ]); + let meta = extract_metadata(&wasm).unwrap(); + assert_eq!(meta.bldimg, good_bldimg()); + assert_eq!( + meta.source_repo.as_deref(), + Some("https://github.com/foo/bar") + ); + assert_eq!(meta.source_rev.as_deref(), Some("b".repeat(40).as_str())); + assert_eq!( + meta.bldopts, + vec![ + "--locked".to_string(), + "--meta=home_domain=fnando.com".to_string() + ] + ); + assert!(meta.tarball_url.is_none()); + assert!(meta.tarball_sha256.is_none()); + } + + #[test] + fn extract_metadata_happy_path_tarball_pair() { + let wasm = make_wasm_with_meta(&[ + ("bldimg", &good_bldimg()), + ("tarball_url", "https://example.com/src.tar.gz"), + ("tarball_sha256", &"f".repeat(64)), + ("bldopt", "--locked"), + ]); + let meta = extract_metadata(&wasm).unwrap(); + assert_eq!( + meta.tarball_url.as_deref(), + Some("https://example.com/src.tar.gz") + ); + assert_eq!( + meta.tarball_sha256.as_deref(), + Some("f".repeat(64).as_str()) + ); + assert!(meta.source_repo.is_none()); + assert!(meta.source_rev.is_none()); + } + + #[test] + fn extract_metadata_missing_bldimg_errors() { + let wasm = make_wasm_with_meta(&[ + ("source_repo", "https://github.com/foo/bar"), + ("source_rev", &"b".repeat(40)), + ]); + let err = extract_metadata(&wasm).unwrap_err(); + assert!(matches!(err, Error::MissingBldimg)); + } + + #[test] + fn extract_metadata_missing_source_id_errors() { + let wasm = make_wasm_with_meta(&[("bldimg", &good_bldimg())]); + let err = extract_metadata(&wasm).unwrap_err(); + assert!(matches!(err, Error::MissingSourceId)); + } + + #[test] + fn extract_metadata_source_rev_without_repo_errors() { + let wasm = + make_wasm_with_meta(&[("bldimg", &good_bldimg()), ("source_rev", &"b".repeat(40))]); + let err = extract_metadata(&wasm).unwrap_err(); + assert!(matches!(err, Error::SourceRevWithoutRepo)); + } + + #[test] + fn extract_metadata_bad_bldimg_format_errors() { + let wasm = make_wasm_with_meta(&[ + ("bldimg", "stellar/stellar-cli@sha256:abc"), // implicit hub + short + ("source_repo", "https://github.com/foo/bar"), + ("source_rev", &"b".repeat(40)), + ]); + let err = extract_metadata(&wasm).unwrap_err(); + assert!(matches!( + err, + Error::MetaFormat { + field: "bldimg", + .. + } + )); + } + + #[test] + fn extract_metadata_bad_source_rev_format_errors() { + let wasm = make_wasm_with_meta(&[ + ("bldimg", &good_bldimg()), + ("source_repo", "https://github.com/foo/bar"), + ("source_rev", "not-a-sha"), + ]); + let err = extract_metadata(&wasm).unwrap_err(); + assert!(matches!( + err, + Error::MetaFormat { + field: "source_rev", + .. + } + )); + } + + #[test] + fn extract_metadata_bad_tarball_sha256_format_errors() { + let wasm = make_wasm_with_meta(&[("bldimg", &good_bldimg()), ("tarball_sha256", "abc")]); + let err = extract_metadata(&wasm).unwrap_err(); + assert!(matches!( + err, + Error::MetaFormat { + field: "tarball_sha256", + .. + } + )); + } + + #[test] + fn extract_metadata_ignores_cliver_and_user_meta() { + let wasm = make_wasm_with_meta(&[ + ("bldimg", &good_bldimg()), + ("source_repo", "https://github.com/foo/bar"), + ("source_rev", &"b".repeat(40)), + ("cliver", "26.0.0#abcdef"), + ("home_domain", "fnando.com"), + ("author", "alice"), + ]); + let meta = extract_metadata(&wasm).unwrap(); + // cliver and user meta land in neither bldopts nor source-ids. + assert!(meta.bldopts.is_empty()); + } + + #[test] + fn extract_metadata_empty_meta_errors() { + let wasm = empty_wasm_module(); // no contractmetav0 section + let err = extract_metadata(&wasm).unwrap_err(); + assert!(matches!(err, Error::NoMeta)); + } +} From dfc21c9b421ae07045033584dde6feca3fa992bb Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 17:04:26 -0700 Subject: [PATCH 02/20] Add trust gates to stellar contract verify. --- .../src/commands/contract/verify.rs | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index 2ed91e7c8..af2ffbda6 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -1,6 +1,8 @@ +use std::io::{IsTerminal, Write}; use std::path::PathBuf; use clap::Parser; +use regex::Regex; use soroban_spec_tools::contract::Spec; use stellar_xdr::curr::{ScMetaEntry, ScMetaV0}; @@ -87,6 +89,70 @@ pub enum Error { #[error("the WASM records `source_rev` but not `source_repo`; SEP-58 requires both together")] SourceRevWithoutRepo, + + #[error("{kind} {value:?} is not in the default trust list, and stdin is not a terminal so we can't ask. Re-run with --trust to proceed.")] + TrustRequired { kind: TrustKind, value: String }, + + #[error("user declined to trust the {kind}; aborting")] + TrustDeclined { kind: TrustKind }, + + #[error("reading stdin: {0}")] + Stdin(std::io::Error), +} + +/// What kind of source is being trust-checked. Affects the default-trust +/// decision and shapes the prompt + error wording. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrustKind { + Bldimg, + Tarball, +} + +impl std::fmt::Display for TrustKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TrustKind::Bldimg => write!(f, "bldimg"), + TrustKind::Tarball => write!(f, "tarball"), + } + } +} + +/// Resolution of a single trust check before any I/O happens. Pure function of +/// the input — the run() side decides what to do with each variant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrustDecision { + /// The value matches the default trust list for its kind. Proceed silently. + Trusted, + /// The value is not trusted by default, but `--trust` was passed. Proceed + /// (and the caller may want to log). + Overridden, + /// Not trusted; the caller must prompt (TTY) or fail (non-TTY). + NeedsConfirmation, +} + +/// SEP-58 places no defaults on which images are trustworthy; we hardcode the +/// canonical `docker.io/stellar/stellar-cli` repo (digest-pinned) as the only +/// default-trusted image. Any other image — including mirrors and forks — +/// requires explicit confirmation. +const TRUSTED_BLDIMG_REGEX_STR: &str = r"^docker\.io/stellar/stellar-cli@sha256:[0-9a-f]{64}$"; + +fn trusted_bldimg_regex() -> Regex { + Regex::new(TRUSTED_BLDIMG_REGEX_STR).unwrap() +} + +/// Pure trust decision; no I/O. Tarball sources are never default-trusted. +pub fn trust_decision(value: &str, kind: TrustKind, trust_flag: bool) -> TrustDecision { + let default_trusted = match kind { + TrustKind::Bldimg => trusted_bldimg_regex().is_match(value), + TrustKind::Tarball => false, + }; + if default_trusted { + TrustDecision::Trusted + } else if trust_flag { + TrustDecision::Overridden + } else { + TrustDecision::NeedsConfirmation + } } /// SEP-58 metadata extracted from a contract's `contractmetav0` section. @@ -131,9 +197,27 @@ impl Cmd { } } + // bldimg trust check is always required. + require_trust(self.trust, TrustKind::Bldimg, &meta.bldimg, &print)?; + + // Tarball source: trust the URL we will actually fetch from (either the + // value the WASM recorded, or the user's `--tarball-url` override). + if let Some(url) = self.effective_tarball_url(&meta) { + require_trust(self.trust, TrustKind::Tarball, &url, &print)?; + } + Ok(()) } + /// The tarball URL we'll actually retrieve from: the cli override if set, + /// otherwise the value recorded in the WASM. Returns `None` for git-source + /// builds (which aren't trust-checked here). + fn effective_tarball_url(&self, meta: &ExtractedMetadata) -> Option { + self.tarball_url + .clone() + .or_else(|| meta.tarball_url.clone()) + } + async fn fetch_wasm(&self) -> Result, Error> { match (&self.contract_id, &self.wasm) { (Some(id), None) => { @@ -250,6 +334,64 @@ pub fn extract_metadata(wasm: &[u8]) -> Result { }) } +/// Apply the trust decision: silent-OK, log-and-OK on override, or +/// prompt-vs-fail on `NeedsConfirmation` depending on whether stdin is a TTY. +fn require_trust( + trust_flag: bool, + kind: TrustKind, + value: &str, + print: &Print, +) -> Result<(), Error> { + match trust_decision(value, kind, trust_flag) { + TrustDecision::Trusted => Ok(()), + TrustDecision::Overridden => { + print.warnln(format!( + "trusting {kind} {value} because --trust was passed" + )); + Ok(()) + } + TrustDecision::NeedsConfirmation => { + if !std::io::stdin().is_terminal() { + return Err(Error::TrustRequired { + kind, + value: value.to_string(), + }); + } + confirm_interactively(kind, value) + } + } +} + +fn confirm_interactively(kind: TrustKind, value: &str) -> Result<(), Error> { + let prompt = match kind { + TrustKind::Bldimg => format!( + "Image {value} is not in the default trust list (only docker.io/stellar/stellar-cli is trusted by default)." + ), + TrustKind::Tarball => format!( + "Tarball source {value} is not trusted by default. Tarballs always require confirmation." + ), + }; + eprintln!("{prompt}"); + eprint!("Trust this {kind} and continue? [y/N] "); + std::io::stderr().flush().ok(); + let mut line = String::new(); + std::io::stdin() + .read_line(&mut line) + .map_err(Error::Stdin)?; + if parse_yes(&line) { + Ok(()) + } else { + Err(Error::TrustDeclined { kind }) + } +} + +/// Accepts y / Y / yes / YES / Yes (case-insensitive). Anything else, including +/// the empty string, is "no" — trust prompts default to declined. +pub fn parse_yes(answer: &str) -> bool { + let a = answer.trim(); + a.eq_ignore_ascii_case("y") || a.eq_ignore_ascii_case("yes") +} + // These mirror the regex strings used in verifiable.rs. They're kept here only // so `Error::MetaFormat` can render the regex back to the user as part of the // error message. The actual matching uses the helpers from verifiable.rs. @@ -439,4 +581,82 @@ mod tests { let err = extract_metadata(&wasm).unwrap_err(); assert!(matches!(err, Error::NoMeta)); } + + #[test] + fn trust_decision_bldimg_canonical_is_trusted() { + let img = format!("docker.io/stellar/stellar-cli@sha256:{}", "a".repeat(64)); + assert_eq!( + trust_decision(&img, TrustKind::Bldimg, false), + TrustDecision::Trusted + ); + assert_eq!( + trust_decision(&img, TrustKind::Bldimg, true), + TrustDecision::Trusted + ); + } + + #[test] + fn trust_decision_bldimg_other_registry_needs_confirmation() { + let img = format!("ghcr.io/stellar/stellar-cli@sha256:{}", "a".repeat(64)); + assert_eq!( + trust_decision(&img, TrustKind::Bldimg, false), + TrustDecision::NeedsConfirmation + ); + assert_eq!( + trust_decision(&img, TrustKind::Bldimg, true), + TrustDecision::Overridden + ); + } + + #[test] + fn trust_decision_bldimg_other_repo_on_dockerhub_needs_confirmation() { + // Same registry but different repo (fork) — not trusted. + let img = format!("docker.io/fnando/stellar-cli@sha256:{}", "a".repeat(64)); + assert_eq!( + trust_decision(&img, TrustKind::Bldimg, false), + TrustDecision::NeedsConfirmation + ); + } + + #[test] + fn trust_decision_tarball_always_needs_confirmation() { + assert_eq!( + trust_decision( + "https://github.com/foo/bar.tar.gz", + TrustKind::Tarball, + false + ), + TrustDecision::NeedsConfirmation + ); + assert_eq!( + trust_decision("/local/foo.tar.gz", TrustKind::Tarball, false), + TrustDecision::NeedsConfirmation + ); + } + + #[test] + fn trust_decision_tarball_override_with_trust() { + assert_eq!( + trust_decision( + "https://github.com/foo/bar.tar.gz", + TrustKind::Tarball, + true + ), + TrustDecision::Overridden + ); + } + + #[test] + fn parse_yes_accepts_all_case_variants() { + for yes in ["y", "Y", "yes", "YES", "Yes", "yEs", " y ", "yes\n"] { + assert!(parse_yes(yes), "{yes:?} should be yes"); + } + } + + #[test] + fn parse_yes_rejects_anything_else() { + for no in ["", "n", "N", "no", "NO", "x", "yup", "yeah", " "] { + assert!(!parse_yes(no), "{no:?} should not be yes"); + } + } } From 8176d652dd863e189987ea53b466c74261b9c9d6 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 17:10:40 -0700 Subject: [PATCH 03/20] Materialize source for stellar contract verify. --- cmd/soroban-cli/Cargo.toml | 2 +- .../src/commands/contract/build/verifiable.rs | 4 +- .../src/commands/contract/verify.rs | 408 +++++++++++------- 3 files changed, 250 insertions(+), 164 deletions(-) diff --git a/cmd/soroban-cli/Cargo.toml b/cmd/soroban-cli/Cargo.toml index 60bd65556..5648ecf13 100644 --- a/cmd/soroban-cli/Cargo.toml +++ b/cmd/soroban-cli/Cargo.toml @@ -112,6 +112,7 @@ futures-util = "0.3.30" futures = "0.3.30" home = "0.5.9" flate2 = "1.0.30" +tar = "0.4.46" bytesize = "1.3.0" humantime = "2.1.0" phf = { version = "0.11.2", features = ["macros"] } @@ -126,7 +127,6 @@ keyring = { version = "3", features = ["apple-native", "windows-native", "sync-s whoami = "1.5.2" serde_with = "3.11.0" rustc_version = "0.4.1" -tar = "0.4.40" walkdir = "2.5.0" [build-dependencies] diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 1f4402c4a..83a491af9 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -363,11 +363,11 @@ pub(crate) fn bldimg_regex() -> Regex { .unwrap() } -fn source_sha256_regex() -> Regex { +pub(crate) fn source_sha256_regex() -> Regex { Regex::new(r"^[0-9a-f]{64}$").unwrap() } -fn source_uri_regex() -> Regex { +pub(crate) fn source_uri_regex() -> Regex { Regex::new(r"^[a-zA-Z][a-zA-Z0-9+.-]*:\S+$").unwrap() } diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index af2ffbda6..616b84372 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -1,16 +1,16 @@ use std::io::{IsTerminal, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use clap::Parser; use regex::Regex; +use sha2::{Digest, Sha256}; use soroban_spec_tools::contract::Spec; use stellar_xdr::curr::{ScMetaEntry, ScMetaV0}; use crate::{ commands::{ contract::build::verifiable::{ - bldimg_regex, source_repo_regex, source_rev_regex, tarball_sha256_regex, - tarball_url_regex, + bldimg_regex, source_uri_regex, source_sha256_regex }, global, }, @@ -30,11 +30,11 @@ pub struct Cmd { #[arg(long)] pub wasm: Option, - /// Local tarball file or http(s) URL to use as the source when the WASM's - /// recorded SEP-58 metadata has only `tarball_sha256` (no `tarball_url`). + /// Local source code file or http(s) URL to use as the source when the WASM's + /// recorded SEP-58 metadata has only `source_sha256` (no `source_uri`). /// Accepts http(s) URLs or local file paths. #[arg(long)] - pub tarball_url: Option, + pub source_uri: Option, /// Bypass interactive confirmation when the WASM's bldimg is not in the /// default trust list, or when the source is a tarball (tarballs are @@ -75,8 +75,8 @@ pub enum Error { #[error("the WASM's contractmetav0 does not record a `bldimg` entry; cannot verify")] MissingBldimg, - #[error("the WASM's contractmetav0 does not record any SEP-58 source-identification entry (source_repo+source_rev, tarball_url, or tarball_sha256); cannot verify")] - MissingSourceId, + #[error("the WASM's contractmetav0 does not record a `source_sha256` entry; cannot verify")] + MissingSourceSha256, #[error( "the WASM's `{field}` value {value:?} does not match the SEP-58 format regex `{regex}`" @@ -87,9 +87,6 @@ pub enum Error { regex: &'static str, }, - #[error("the WASM records `source_rev` but not `source_repo`; SEP-58 requires both together")] - SourceRevWithoutRepo, - #[error("{kind} {value:?} is not in the default trust list, and stdin is not a terminal so we can't ask. Re-run with --trust to proceed.")] TrustRequired { kind: TrustKind, value: String }, @@ -98,6 +95,30 @@ pub enum Error { #[error("reading stdin: {0}")] Stdin(std::io::Error), + + #[error("the WASM records only `source_sha256` (no `source_uri`). Pass `--source-uri URL_OR_PATH` to provide retrieval.")] + SourceUriRequired, + + #[error("downloading {url}: {source}")] + SourceDownload { url: String, source: reqwest::Error }, + + #[error("reading local source code {path}: {source}")] + SourceRead { + path: PathBuf, + source: std::io::Error, + }, + + #[error("source code sha256 mismatch: expected {expected}, got {actual}")] + SourceHashMismatch { expected: String, actual: String }, + + #[error("extracting source code into {path}: {source}")] + SourceExtract { + path: PathBuf, + source: std::io::Error, + }, + + #[error("creating tempdir: {0}")] + TempDir(std::io::Error), } /// What kind of source is being trust-checked. Affects the default-trust @@ -163,10 +184,8 @@ pub fn trust_decision(value: &str, kind: TrustKind, trust_flag: bool) -> TrustDe #[derive(Debug, Clone)] pub struct ExtractedMetadata { pub bldimg: String, - pub source_repo: Option, - pub source_rev: Option, - pub tarball_url: Option, - pub tarball_sha256: Option, + pub source_uri: Option, + pub source_sha256: Option, pub bldopts: Vec, } @@ -178,18 +197,15 @@ impl Cmd { let meta = extract_metadata(&wasm_bytes)?; print.infoln(format!("bldimg: {}", meta.bldimg)); - if let Some(v) = &meta.source_repo { - print.infoln(format!("source_repo: {v}")); - } - if let Some(v) = &meta.source_rev { - print.infoln(format!("source_rev: {v}")); - } - if let Some(v) = &meta.tarball_url { - print.infoln(format!("tarball_url: {v}")); + + if let Some(v) = &meta.source_uri { + print.infoln(format!("source_uri: {v}")); } - if let Some(v) = &meta.tarball_sha256 { - print.infoln(format!("tarball_sha256: {v}")); + + if let Some(v) = &meta.source_sha256 { + print.infoln(format!("source_sha256: {v}")); } + if !meta.bldopts.is_empty() { print.infoln(format!("bldopt entries ({}):", meta.bldopts.len())); for o in &meta.bldopts { @@ -201,21 +217,33 @@ impl Cmd { require_trust(self.trust, TrustKind::Bldimg, &meta.bldimg, &print)?; // Tarball source: trust the URL we will actually fetch from (either the - // value the WASM recorded, or the user's `--tarball-url` override). - if let Some(url) = self.effective_tarball_url(&meta) { + // value the WASM recorded, or the user's `--source-uri` override). + if let Some(url) = self.effective_source_uri(&meta) { require_trust(self.trust, TrustKind::Tarball, &url, &print)?; } + // Materialize the recorded source into a tempdir so the next step + // (the rebuild — to land in a follow-up commit) can bind-mount it. + // The TempDir keeps the directory alive only for this scope; the + // rebuild needs to happen before we return. + let workdir = tempfile::TempDir::new().map_err(Error::TempDir)?; + materialize_source(&meta, self.source_uri.as_deref(), workdir.path(), &print).await?; + print.checkln(format!( + "Source materialized at {}", + workdir.path().display() + )); + Ok(()) } /// The tarball URL we'll actually retrieve from: the cli override if set, - /// otherwise the value recorded in the WASM. Returns `None` for git-source - /// builds (which aren't trust-checked here). - fn effective_tarball_url(&self, meta: &ExtractedMetadata) -> Option { - self.tarball_url + /// otherwise the value recorded in the WASM. Returns `None` when neither + /// records a `source_uri` (only `source_sha256` is set), in which case + /// there's nothing to trust-check here. + fn effective_source_uri(&self, meta: &ExtractedMetadata) -> Option { + self.source_uri .clone() - .or_else(|| meta.tarball_url.clone()) + .or_else(|| meta.source_uri.clone()) } async fn fetch_wasm(&self) -> Result, Error> { @@ -233,8 +261,8 @@ impl Cmd { } /// Walk the WASM's `contractmetav0` entries and pull out the SEP-58 fields we -/// need to drive a rebuild. Errors when `bldimg` is absent or when no source -/// identification is recorded, since neither has a sensible default. +/// need to drive a rebuild. Errors when `bldimg` or `source_sha256` is absent, +/// since neither has a sensible default. `source_uri` is optional. pub fn extract_metadata(wasm: &[u8]) -> Result { let spec = Spec::new(wasm)?; if spec.meta.is_empty() { @@ -242,10 +270,8 @@ pub fn extract_metadata(wasm: &[u8]) -> Result { } let mut bldimg: Option = None; - let mut source_repo: Option = None; - let mut source_rev: Option = None; - let mut tarball_url: Option = None; - let mut tarball_sha256: Option = None; + let mut source_uri: Option = None; + let mut source_sha256: Option = None; let mut bldopts: Vec = Vec::new(); for entry in &spec.meta { @@ -254,10 +280,8 @@ pub fn extract_metadata(wasm: &[u8]) -> Result { let v = val.to_string(); match k.as_str() { "bldimg" => bldimg = Some(v), - "source_repo" => source_repo = Some(v), - "source_rev" => source_rev = Some(v), - "tarball_url" => tarball_url = Some(v), - "tarball_sha256" => tarball_sha256 = Some(v), + "source_uri" => source_uri = Some(v), + "source_sha256" => source_sha256 = Some(v), "bldopt" => bldopts.push(v), _ => {} // cliver and any user --meta are intentionally ignored } @@ -272,64 +296,33 @@ pub fn extract_metadata(wasm: &[u8]) -> Result { }); } - if let Some(v) = &source_rev { - if !source_rev_regex().is_match(v) { - return Err(Error::MetaFormat { - field: "source_rev", - value: v.clone(), - regex: SOURCE_REV_REGEX_STR, - }); - } - } - if let Some(v) = &source_repo { - if !source_repo_regex().is_match(v) { - return Err(Error::MetaFormat { - field: "source_repo", - value: v.clone(), - regex: SOURCE_REPO_REGEX_STR, - }); - } - } - if let Some(v) = &tarball_url { - if !tarball_url_regex().is_match(v) { + if let Some(v) = &source_uri { + if !source_uri_regex().is_match(v) { return Err(Error::MetaFormat { - field: "tarball_url", + field: "source_uri", value: v.clone(), - regex: TARBALL_URL_REGEX_STR, + regex: SOURCE_URL_REGEX_STR, }); } } - if let Some(v) = &tarball_sha256 { - if !tarball_sha256_regex().is_match(v) { + if let Some(v) = &source_sha256 { + if !source_sha256_regex().is_match(v) { return Err(Error::MetaFormat { - field: "tarball_sha256", + field: "source_sha256", value: v.clone(), - regex: TARBALL_SHA256_REGEX_STR, + regex: SOURCE_SHA256_REGEX_STR, }); } } - // SEP-58 lists `source_repo+source_rev` as a conformant combination. We - // refuse `source_rev` without `source_repo` here so the user sees a - // pointed error rather than a downstream "can't clone repo" surprise. - if source_rev.is_some() && source_repo.is_none() { - return Err(Error::SourceRevWithoutRepo); - } - - if source_repo.is_none() - && source_rev.is_none() - && tarball_url.is_none() - && tarball_sha256.is_none() - { - return Err(Error::MissingSourceId); + if source_sha256.is_none() { + return Err(Error::MissingSourceSha256); } Ok(ExtractedMetadata { bldimg, - source_repo, - source_rev, - tarball_url, - tarball_sha256, + source_uri, + source_sha256, bldopts, }) } @@ -392,15 +385,101 @@ pub fn parse_yes(answer: &str) -> bool { a.eq_ignore_ascii_case("y") || a.eq_ignore_ascii_case("yes") } +/// Materialize the recorded source tree into `target`. Picks the path based on +/// what the WASM recorded: +/// - source_uri (with optional sha256) → download/read, optional sha-check, +/// extract via `tar` +/// - source_sha256 only → require `--source-uri` on the cli and use it as +/// the retrieval channel +/// +/// `source_uri_override` is the cli's `--source-uri` flag value; when set, it +/// wins over whatever the WASM recorded, and may be an http(s) URL or a local +/// file path. +async fn materialize_source( + meta: &ExtractedMetadata, + source_uri_override: Option<&str>, + target: &Path, + print: &Print, +) -> Result<(), Error> { + let tarball_source = source_uri_override + .map(str::to_string) + .or_else(|| meta.source_uri.clone()); + let Some(source) = tarball_source else { + // No source_uri anywhere — only source_sha256 is set. + return Err(Error::SourceUriRequired); + }; + + print.infoln(format!("Fetching source code from {source}")); + let bytes = fetch_tarball_bytes(&source).await?; + + if let Some(expected) = &meta.source_sha256 { + verify_source_sha256(&bytes, expected)?; + print.checkln("source code sha256 matches"); + } + extract_tarball(&bytes, target)?; + Ok(()) +} + +/// Retrieve the tarball bytes. `source` is either an `http(s)://` URL or a +/// local file path. The split is by prefix, not by attempting both — keeps +/// behavior predictable. +async fn fetch_tarball_bytes(source: &str) -> Result, Error> { + if source.starts_with("http://") || source.starts_with("https://") { + let resp = reqwest::get(source) + .await + .map_err(|e| Error::SourceDownload { + url: source.to_string(), + source: e, + })?; + let bytes = resp + .error_for_status() + .map_err(|e| Error::SourceDownload { + url: source.to_string(), + source: e, + })? + .bytes() + .await + .map_err(|e| Error::SourceDownload { + url: source.to_string(), + source: e, + })?; + Ok(bytes.to_vec()) + } else { + std::fs::read(source).map_err(|e| Error::SourceRead { + path: PathBuf::from(source), + source: e, + }) + } +} + +fn verify_source_sha256(bytes: &[u8], expected: &str) -> Result<(), Error> { + let actual = format!("{:x}", Sha256::digest(bytes)); + if actual.eq_ignore_ascii_case(expected) { + Ok(()) + } else { + Err(Error::SourceHashMismatch { + expected: expected.to_string(), + actual, + }) + } +} + +fn extract_tarball(bytes: &[u8], target: &Path) -> Result<(), Error> { + let gz = flate2::read::GzDecoder::new(bytes); + let mut archive = tar::Archive::new(gz); + archive.unpack(target).map_err(|e| Error::SourceExtract { + path: target.to_path_buf(), + source: e, + }) +} + // These mirror the regex strings used in verifiable.rs. They're kept here only // so `Error::MetaFormat` can render the regex back to the user as part of the // error message. The actual matching uses the helpers from verifiable.rs. const BLDIMG_REGEX_STR: &str = r"^(?:localhost(?::\d+)?|[^\s@/]*[.:][^\s@/]*)/[^\s@]+@sha256:[0-9a-f]{64}$"; -const SOURCE_REV_REGEX_STR: &str = r"^[0-9a-f]{40}$"; -const SOURCE_REPO_REGEX_STR: &str = r"^(https?://\S+|github:[^/\s]+/[^/\s]+)$"; -const TARBALL_URL_REGEX_STR: &str = r"^https?://\S+$"; -const TARBALL_SHA256_REGEX_STR: &str = r"^[0-9a-f]{64}$"; +const SOURCE_URL_REGEX_STR: &str = r"^https?://\S+$"; +const SOURCE_SHA256_REGEX_STR: &str = r"^[0-9a-f]{64}$"; #[cfg(test)] mod tests { @@ -438,62 +517,28 @@ mod tests { format!("docker.io/stellar/stellar-cli@sha256:{}", "a".repeat(64)) } - #[test] - fn extract_metadata_happy_path_git_source() { - let wasm = make_wasm_with_meta(&[ - ("bldimg", &good_bldimg()), - ("source_repo", "https://github.com/foo/bar"), - ("source_rev", &"b".repeat(40)), - ("bldopt", "--locked"), - ("bldopt", "--meta=home_domain=fnando.com"), - ("home_domain", "fnando.com"), - ("cliver", "26.0.0#abcdef"), - ]); - let meta = extract_metadata(&wasm).unwrap(); - assert_eq!(meta.bldimg, good_bldimg()); - assert_eq!( - meta.source_repo.as_deref(), - Some("https://github.com/foo/bar") - ); - assert_eq!(meta.source_rev.as_deref(), Some("b".repeat(40).as_str())); - assert_eq!( - meta.bldopts, - vec![ - "--locked".to_string(), - "--meta=home_domain=fnando.com".to_string() - ] - ); - assert!(meta.tarball_url.is_none()); - assert!(meta.tarball_sha256.is_none()); - } - #[test] fn extract_metadata_happy_path_tarball_pair() { let wasm = make_wasm_with_meta(&[ ("bldimg", &good_bldimg()), - ("tarball_url", "https://example.com/src.tar.gz"), - ("tarball_sha256", &"f".repeat(64)), + ("source_uri", "https://example.com/src.tar.gz"), + ("source_sha256", &"f".repeat(64)), ("bldopt", "--locked"), ]); let meta = extract_metadata(&wasm).unwrap(); assert_eq!( - meta.tarball_url.as_deref(), + meta.source_uri.as_deref(), Some("https://example.com/src.tar.gz") ); assert_eq!( - meta.tarball_sha256.as_deref(), + meta.source_sha256.as_deref(), Some("f".repeat(64).as_str()) ); - assert!(meta.source_repo.is_none()); - assert!(meta.source_rev.is_none()); } #[test] fn extract_metadata_missing_bldimg_errors() { - let wasm = make_wasm_with_meta(&[ - ("source_repo", "https://github.com/foo/bar"), - ("source_rev", &"b".repeat(40)), - ]); + let wasm = make_wasm_with_meta(&[("source_sha256", &"b".repeat(64))]); let err = extract_metadata(&wasm).unwrap_err(); assert!(matches!(err, Error::MissingBldimg)); } @@ -502,23 +547,14 @@ mod tests { fn extract_metadata_missing_source_id_errors() { let wasm = make_wasm_with_meta(&[("bldimg", &good_bldimg())]); let err = extract_metadata(&wasm).unwrap_err(); - assert!(matches!(err, Error::MissingSourceId)); - } - - #[test] - fn extract_metadata_source_rev_without_repo_errors() { - let wasm = - make_wasm_with_meta(&[("bldimg", &good_bldimg()), ("source_rev", &"b".repeat(40))]); - let err = extract_metadata(&wasm).unwrap_err(); - assert!(matches!(err, Error::SourceRevWithoutRepo)); + assert!(matches!(err, Error::MissingSourceSha256)); } #[test] fn extract_metadata_bad_bldimg_format_errors() { let wasm = make_wasm_with_meta(&[ ("bldimg", "stellar/stellar-cli@sha256:abc"), // implicit hub + short - ("source_repo", "https://github.com/foo/bar"), - ("source_rev", &"b".repeat(40)), + ("source_sha256", &"b".repeat(64)), ]); let err = extract_metadata(&wasm).unwrap_err(); assert!(matches!( @@ -531,30 +567,13 @@ mod tests { } #[test] - fn extract_metadata_bad_source_rev_format_errors() { - let wasm = make_wasm_with_meta(&[ - ("bldimg", &good_bldimg()), - ("source_repo", "https://github.com/foo/bar"), - ("source_rev", "not-a-sha"), - ]); + fn extract_metadata_bad_source_sha256_format_errors() { + let wasm = make_wasm_with_meta(&[("bldimg", &good_bldimg()), ("source_sha256", "abc")]); let err = extract_metadata(&wasm).unwrap_err(); assert!(matches!( err, Error::MetaFormat { - field: "source_rev", - .. - } - )); - } - - #[test] - fn extract_metadata_bad_tarball_sha256_format_errors() { - let wasm = make_wasm_with_meta(&[("bldimg", &good_bldimg()), ("tarball_sha256", "abc")]); - let err = extract_metadata(&wasm).unwrap_err(); - assert!(matches!( - err, - Error::MetaFormat { - field: "tarball_sha256", + field: "source_sha256", .. } )); @@ -564,8 +583,7 @@ mod tests { fn extract_metadata_ignores_cliver_and_user_meta() { let wasm = make_wasm_with_meta(&[ ("bldimg", &good_bldimg()), - ("source_repo", "https://github.com/foo/bar"), - ("source_rev", &"b".repeat(40)), + ("source_sha256", &"b".repeat(64)), ("cliver", "26.0.0#abcdef"), ("home_domain", "fnando.com"), ("author", "alice"), @@ -659,4 +677,72 @@ mod tests { assert!(!parse_yes(no), "{no:?} should not be yes"); } } + + #[test] + fn verify_source_sha256_matches() { + let bytes = b"hello, sep-58"; + let digest = format!("{:x}", Sha256::digest(bytes)); + verify_source_sha256(bytes, &digest).unwrap(); + // Case-insensitive: SEP-58 mandates lowercase but be lenient on input. + verify_source_sha256(bytes, &digest.to_ascii_uppercase()).unwrap(); + } + + #[test] + fn verify_source_sha256_mismatch_errors() { + let bytes = b"hello, sep-58"; + let bogus = "0".repeat(64); + let err = verify_source_sha256(bytes, &bogus).unwrap_err(); + assert!(matches!(err, Error::SourceHashMismatch { .. })); + } + + /// Build a tiny in-memory tar.gz with a single file and confirm extraction + /// drops the file at the expected path. Exercises the pure-Rust pipeline + /// (no shelling out, so this passes on Windows too). + #[test] + fn extract_tarball_unpacks_into_target() { + use flate2::write::GzEncoder; + use flate2::Compression; + use std::io::Write; + + let mut tar_bytes = Vec::new(); + { + let mut builder = tar::Builder::new(&mut tar_bytes); + let payload = b"contents"; + let mut header = tar::Header::new_gnu(); + header.set_path("hello.txt").unwrap(); + header.set_size(payload.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append(&header, &payload[..]).unwrap(); + builder.finish().unwrap(); + } + + let mut gz = Vec::new(); + { + let mut enc = GzEncoder::new(&mut gz, Compression::default()); + enc.write_all(&tar_bytes).unwrap(); + enc.finish().unwrap(); + } + + let dir = tempfile::TempDir::new().unwrap(); + extract_tarball(&gz, dir.path()).unwrap(); + let extracted = std::fs::read(dir.path().join("hello.txt")).unwrap(); + assert_eq!(extracted, b"contents"); + } + + #[tokio::test] + async fn materialize_source_errors_when_only_source_sha256() { + let meta = ExtractedMetadata { + bldimg: good_bldimg(), + source_uri: None, + source_sha256: Some("f".repeat(64)), + bldopts: Vec::new(), + }; + let dir = tempfile::TempDir::new().unwrap(); + let print = Print::new(true); + let err = materialize_source(&meta, None, dir.path(), &print) + .await + .unwrap_err(); + assert!(matches!(err, Error::SourceUriRequired)); + } } From 4b3825bfbd1ad2cade5c536b61470eeaf3532026 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 17:16:43 -0700 Subject: [PATCH 04/20] Rebuild and byte-compare in stellar contract verify. --- FULL_HELP_DOCS.md | 1 + .../src/commands/contract/build/verifiable.rs | 6 +- .../src/commands/contract/verify.rs | 306 +++++++++++++++++- 3 files changed, 304 insertions(+), 9 deletions(-) diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index fa897c674..5c030210c 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -1168,6 +1168,7 @@ Verify that a contract's WASM reproduces from the build metadata it records, per - `--wasm ` — Local WASM file to verify, instead of fetching from the network - `--tarball-url ` — Local tarball file or http(s) URL to use as the source when the WASM's recorded SEP-58 metadata has only `tarball_sha256` (no `tarball_url`). Accepts http(s) URLs or local file paths - `--trust` — Bypass interactive confirmation when the WASM's bldimg is not in the default trust list, or when the source is a tarball (tarballs are never default-trusted) +- `-d`, `--docker-host ` — Optional argument to override the default docker host. This is useful when you are using a non-standard docker host path for your Docker-compatible container runtime, e.g. Docker Desktop defaults to $HOME/.docker/run/docker.sock instead of /var/run/docker.sock ###### **RPC Options:** diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 83a491af9..3cf56e1c0 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -527,7 +527,7 @@ fn build_metadata_args(image_ref: &str, ids: &SourceIds, bldopts: &[String]) -> out } -fn compose_container_args(forwarded: &[String], metadata: &[String]) -> Vec { +pub(crate) fn compose_container_args(forwarded: &[String], metadata: &[String]) -> Vec { let mut args = vec!["contract".to_string(), "build".to_string()]; args.extend_from_slice(forwarded); args.extend_from_slice(metadata); @@ -583,7 +583,7 @@ pub async fn resolve_image(cmd: &Cmd, docker: &Docker, print: &Print) -> Result< Ok(digest) } -async fn pull_image( +pub(crate) async fn pull_image( docker: &Docker, tag: &str, print: &Print, @@ -861,7 +861,7 @@ fn escape_container_args(cmd: &[String]) -> String { .join(" ") } -async fn run_in_container( +pub(crate) async fn run_in_container( image_ref: &str, workspace_root: &Path, container_cmds: &[Vec], diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index 616b84372..be9078e2d 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -9,8 +9,9 @@ use stellar_xdr::curr::{ScMetaEntry, ScMetaV0}; use crate::{ commands::{ + container, contract::build::verifiable::{ - bldimg_regex, source_uri_regex, source_sha256_regex + self, bldimg_regex, source_sha256_regex, source_uri_regex, }, global, }, @@ -47,6 +48,9 @@ pub struct Cmd { #[command(flatten)] pub network: network::Args, + + #[command(flatten)] + pub container_args: container::shared::Args, } #[derive(thiserror::Error, Debug)] @@ -119,6 +123,35 @@ pub enum Error { #[error("creating tempdir: {0}")] TempDir(std::io::Error), + + #[error(transparent)] + Verifiable(#[from] verifiable::Error), + + #[error(transparent)] + Bollard(#[from] bollard::errors::Error), + + #[error(transparent)] + DockerConnection(#[from] container::shared::Error), + + #[error("could not find a rebuilt WASM under {target}")] + NoRebuiltWasm { target: PathBuf }, + + #[error("multiple rebuilt WASMs under {target}; pass --package=... in the bldopt entries to disambiguate. Found: {found}")] + AmbiguousRebuiltWasm { target: PathBuf, found: String }, + + #[error("reading rebuilt wasm {path}: {source}")] + ReadRebuilt { + path: PathBuf, + source: std::io::Error, + }, + + #[error("verification failed: rebuilt bytes do not match the original.\n original: {original_size} bytes, sha256={original_hash}\n rebuilt: {rebuilt_size} bytes, sha256={rebuilt_hash}")] + VerificationMismatch { + original_hash: String, + original_size: usize, + rebuilt_hash: String, + rebuilt_size: usize, + }, } /// What kind of source is being trust-checked. Affects the default-trust @@ -222,10 +255,9 @@ impl Cmd { require_trust(self.trust, TrustKind::Tarball, &url, &print)?; } - // Materialize the recorded source into a tempdir so the next step - // (the rebuild — to land in a follow-up commit) can bind-mount it. - // The TempDir keeps the directory alive only for this scope; the - // rebuild needs to happen before we return. + // Materialize the recorded source into a tempdir so the rebuild can + // bind-mount it. TempDir lives across the rebuild + comparison and + // cleans up on drop. let workdir = tempfile::TempDir::new().map_err(Error::TempDir)?; materialize_source(&meta, self.source_uri.as_deref(), workdir.path(), &print).await?; print.checkln(format!( @@ -233,7 +265,46 @@ impl Cmd { workdir.path().display() )); - Ok(()) + // Rebuild in the recorded bldimg. + let docker = self.container_args.connect_to_docker(&print).await?; + verifiable::pull_image(&docker, &meta.bldimg, &print).await?; + let container_cmd = build_container_command(&meta); + verifiable::run_in_container( + &meta.bldimg, + workdir.path(), + &[container_cmd], + &[], + &docker, + &print, + false, + ) + .await?; + + // Locate the rebuilt WASM. The cargo target dir lives under the bind- + // mounted /source, which we mapped to `workdir`. + let rebuilt_path = find_rebuilt_wasm(workdir.path(), &meta)?; + let rebuilt = std::fs::read(&rebuilt_path).map_err(|e| Error::ReadRebuilt { + path: rebuilt_path.clone(), + source: e, + })?; + + // Compare. + let original_hash = format!("{:x}", Sha256::digest(&wasm_bytes)); + let rebuilt_hash = format!("{:x}", Sha256::digest(&rebuilt)); + if original_hash == rebuilt_hash && wasm_bytes.len() == rebuilt.len() { + print.checkln(format!( + "verified: {} bytes, sha256={original_hash}", + wasm_bytes.len() + )); + Ok(()) + } else { + Err(Error::VerificationMismatch { + original_hash, + original_size: wasm_bytes.len(), + rebuilt_hash, + rebuilt_size: rebuilt.len(), + }) + } } /// The tarball URL we'll actually retrieve from: the cli override if set, @@ -473,6 +544,108 @@ fn extract_tarball(bytes: &[u8], target: &Path) -> Result<(), Error> { }) } +/// Compose the argv we hand to the container's `stellar contract build` so +/// that: +/// - the bldopts from the original build become flags (each entry is one +/// token, ready for clap), AND +/// - bldimg / source-ids / bldopt are re-recorded as `--meta` entries so +/// the rebuilt WASM has identical metadata to the original. +/// +/// cliver is intentionally not re-injected — the container's stellar adds it +/// automatically, and it will match the original's iff `bldimg` resolves to +/// the same container. +fn build_container_command(meta: &ExtractedMetadata) -> Vec { + let mut forwarded: Vec = meta.bldopts.clone(); + let mut metadata: Vec = Vec::new(); + + let mut push_meta = |k: &str, v: &str| { + metadata.push("--meta".to_string()); + metadata.push(format!("{k}={v}")); + }; + push_meta("bldimg", &meta.bldimg); + if let Some(v) = &meta.source_uri { + push_meta("source_uri", v); + } + if let Some(v) = &meta.source_sha256 { + push_meta("source_sha256", v); + } + for o in &meta.bldopts { + push_meta("bldopt", o); + } + + // `--locked` is always sent — even if the original somehow lacked it (a + // non-conformant build), the verifier insists on a locked rebuild so + // dependency drift can't move bytes underneath us. + if !forwarded.iter().any(|a| a == "--locked") { + forwarded.insert(0, "--locked".to_string()); + } + + verifiable::compose_container_args(&forwarded, &metadata) +} + +/// Locate the rebuilt WASM under `workdir`. The container writes to +/// `/target/wasm32v1-none/release/.wasm` (or `wasm32-unknown-unknown/release` +/// for older toolchains; check both). If a `--package=` bldopt was +/// recorded, prefer that file. +fn find_rebuilt_wasm(workdir: &Path, meta: &ExtractedMetadata) -> Result { + let preferred_pkg = meta + .bldopts + .iter() + .find_map(|opt| opt.strip_prefix("--package=").map(|s| s.replace('-', "_"))); + + let candidates = [ + workdir.join("target/wasm32v1-none/release"), + workdir.join("target/wasm32-unknown-unknown/release"), + ]; + + let mut found: Vec = Vec::new(); + for dir in &candidates { + if !dir.is_dir() { + continue; + } + for entry in std::fs::read_dir(dir).map_err(|e| Error::ReadRebuilt { + path: dir.clone(), + source: e, + })? { + let p = entry + .map_err(|e| Error::ReadRebuilt { + path: dir.clone(), + source: e, + })? + .path(); + if p.extension().and_then(|s| s.to_str()) == Some("wasm") { + found.push(p); + } + } + } + + if let Some(pkg) = &preferred_pkg { + let want = format!("{pkg}.wasm"); + if let Some(p) = found.iter().find(|p| { + p.file_name() + .and_then(|s| s.to_str()) + .is_some_and(|n| n == want) + }) { + return Ok(p.clone()); + } + } + + match found.len() { + 0 => Err(Error::NoRebuiltWasm { + target: workdir.join("target"), + }), + 1 => Ok(found.into_iter().next().unwrap()), + _ => Err(Error::AmbiguousRebuiltWasm { + target: workdir.join("target"), + found: found + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", "), + }), + } +} + // These mirror the regex strings used in verifiable.rs. They're kept here only // so `Error::MetaFormat` can render the regex back to the user as part of the // error message. The actual matching uses the helpers from verifiable.rs. @@ -745,4 +918,125 @@ mod tests { .unwrap_err(); assert!(matches!(err, Error::SourceUriRequired)); } + + #[test] + fn build_container_command_replays_bldopts_and_re_records_meta() { + let meta = ExtractedMetadata { + bldimg: good_bldimg(), + source_uri: Some("https://github.com/foo/bar".to_string()), + source_sha256: Some("b".repeat(64)), + bldopts: vec![ + "--locked".to_string(), + "--meta=home_domain=fnando.com".to_string(), + "--optimize".to_string(), + ], + }; + let cmd = build_container_command(&meta); + + // Subcommand prefix. + assert_eq!(&cmd[..2], &["contract".to_string(), "build".to_string()]); + + // Bldopts are forwarded verbatim as flags to the inner `stellar contract build`. + assert!(cmd.contains(&"--locked".to_string())); + assert!(cmd.contains(&"--meta=home_domain=fnando.com".to_string())); + assert!(cmd.contains(&"--optimize".to_string())); + + // bldimg and source-ids are re-recorded as `--meta`. + assert!(cmd + .windows(2) + .any(|w| w[0] == "--meta" && w[1] == format!("bldimg={}", good_bldimg()))); + assert!(cmd + .windows(2) + .any(|w| w[0] == "--meta" && w[1] == "source_uri=https://github.com/foo/bar")); + + // Every bldopt is also re-recorded as a `bldopt=` meta so the rebuilt + // WASM mirrors the original's entries. + assert!(cmd + .windows(2) + .any(|w| w[0] == "--meta" && w[1] == "bldopt=--locked")); + } + + #[test] + fn build_container_command_injects_locked_when_missing() { + // A non-conformant origin might not have --locked in bldopts. Verify + // forces it anyway so dependency drift cannot move bytes. + let meta = ExtractedMetadata { + bldimg: good_bldimg(), + source_uri: Some("https://github.com/foo/bar".to_string()), + source_sha256: Some("b".repeat(64)), + bldopts: vec!["--meta=author=alice".to_string()], + }; + let cmd = build_container_command(&meta); + let locked_count = cmd.iter().filter(|s| *s == "--locked").count(); + assert_eq!( + locked_count, 1, + "expected exactly one --locked, got {locked_count} in {cmd:?}" + ); + } + + #[test] + fn find_rebuilt_wasm_picks_single() { + let dir = tempfile::TempDir::new().unwrap(); + let release = dir.path().join("target/wasm32v1-none/release"); + std::fs::create_dir_all(&release).unwrap(); + std::fs::write(release.join("hello.wasm"), b"x").unwrap(); + + let meta = ExtractedMetadata { + bldimg: good_bldimg(), + source_uri: Some("https://github.com/foo/bar".to_string()), + source_sha256: Some("b".repeat(64)), + bldopts: vec![], + }; + let p = find_rebuilt_wasm(dir.path(), &meta).unwrap(); + assert!(p.ends_with("hello.wasm")); + } + + #[test] + fn find_rebuilt_wasm_disambiguates_by_package() { + let dir = tempfile::TempDir::new().unwrap(); + let release = dir.path().join("target/wasm32v1-none/release"); + std::fs::create_dir_all(&release).unwrap(); + std::fs::write(release.join("hello.wasm"), b"x").unwrap(); + std::fs::write(release.join("other_thing.wasm"), b"x").unwrap(); + + let meta = ExtractedMetadata { + bldimg: good_bldimg(), + source_uri: Some("https://github.com/foo/bar".to_string()), + source_sha256: Some("b".repeat(64)), + bldopts: vec!["--package=other-thing".to_string()], + }; + let p = find_rebuilt_wasm(dir.path(), &meta).unwrap(); + assert!(p.ends_with("other_thing.wasm")); + } + + #[test] + fn find_rebuilt_wasm_errors_when_ambiguous_without_package() { + let dir = tempfile::TempDir::new().unwrap(); + let release = dir.path().join("target/wasm32v1-none/release"); + std::fs::create_dir_all(&release).unwrap(); + std::fs::write(release.join("hello.wasm"), b"x").unwrap(); + std::fs::write(release.join("other.wasm"), b"x").unwrap(); + + let meta = ExtractedMetadata { + bldimg: good_bldimg(), + source_uri: Some("https://github.com/foo/bar".to_string()), + source_sha256: Some("b".repeat(64)), + bldopts: vec![], + }; + let err = find_rebuilt_wasm(dir.path(), &meta).unwrap_err(); + assert!(matches!(err, Error::AmbiguousRebuiltWasm { .. })); + } + + #[test] + fn find_rebuilt_wasm_errors_when_none() { + let dir = tempfile::TempDir::new().unwrap(); + let meta = ExtractedMetadata { + bldimg: good_bldimg(), + source_uri: Some("https://github.com/foo/bar".to_string()), + source_sha256: Some("b".repeat(64)), + bldopts: vec![], + }; + let err = find_rebuilt_wasm(dir.path(), &meta).unwrap_err(); + assert!(matches!(err, Error::NoRebuiltWasm { .. })); + } } From 36f9c7721980e7eca7623979d98a43a8fbc85b9d Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 17:29:59 -0700 Subject: [PATCH 05/20] Use direct --docker-host field on contract verify. --- FULL_HELP_DOCS.md | 2 +- cmd/soroban-cli/src/commands/contract/verify.rs | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index 5c030210c..e87a48c44 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -1168,7 +1168,7 @@ Verify that a contract's WASM reproduces from the build metadata it records, per - `--wasm ` — Local WASM file to verify, instead of fetching from the network - `--tarball-url ` — Local tarball file or http(s) URL to use as the source when the WASM's recorded SEP-58 metadata has only `tarball_sha256` (no `tarball_url`). Accepts http(s) URLs or local file paths - `--trust` — Bypass interactive confirmation when the WASM's bldimg is not in the default trust list, or when the source is a tarball (tarballs are never default-trusted) -- `-d`, `--docker-host ` — Optional argument to override the default docker host. This is useful when you are using a non-standard docker host path for your Docker-compatible container runtime, e.g. Docker Desktop defaults to $HOME/.docker/run/docker.sock instead of /var/run/docker.sock +- `-d`, `--docker-host ` — Override the default docker host used by the rebuild ###### **RPC Options:** diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index be9078e2d..56ffbe5f4 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -43,14 +43,15 @@ pub struct Cmd { #[arg(long)] pub trust: bool, + /// Override the default docker host used by the rebuild. + #[arg(short = 'd', long, env = "DOCKER_HOST")] + pub docker_host: Option, + #[command(flatten)] pub locator: locator::Args, #[command(flatten)] pub network: network::Args, - - #[command(flatten)] - pub container_args: container::shared::Args, } #[derive(thiserror::Error, Debug)] @@ -266,7 +267,10 @@ impl Cmd { )); // Rebuild in the recorded bldimg. - let docker = self.container_args.connect_to_docker(&print).await?; + let docker_args = container::shared::Args { + docker_host: self.docker_host.clone(), + }; + let docker = docker_args.connect_to_docker(&print).await?; verifiable::pull_image(&docker, &meta.bldimg, &print).await?; let container_cmd = build_container_command(&meta); verifiable::run_in_container( From 1090011f1b5e47fbcc0abde9843d6f694461fde3 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 17:34:24 -0700 Subject: [PATCH 06/20] Use question and warn emojis on trust prompt. --- cmd/soroban-cli/src/commands/contract/verify.rs | 10 +++++----- cmd/soroban-cli/src/print.rs | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index 56ffbe5f4..cd789cd85 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -425,13 +425,13 @@ fn require_trust( value: value.to_string(), }); } - confirm_interactively(kind, value) + confirm_interactively(kind, value, print) } } } -fn confirm_interactively(kind: TrustKind, value: &str) -> Result<(), Error> { - let prompt = match kind { +fn confirm_interactively(kind: TrustKind, value: &str, print: &Print) -> Result<(), Error> { + let context = match kind { TrustKind::Bldimg => format!( "Image {value} is not in the default trust list (only docker.io/stellar/stellar-cli is trusted by default)." ), @@ -439,8 +439,8 @@ fn confirm_interactively(kind: TrustKind, value: &str) -> Result<(), Error> { "Tarball source {value} is not trusted by default. Tarballs always require confirmation." ), }; - eprintln!("{prompt}"); - eprint!("Trust this {kind} and continue? [y/N] "); + print.warnln(context); + print.question(format!("Trust this {kind} and continue? [y/N] ")); std::io::stderr().flush().ok(); let mut line = String::new(); std::io::stdin() diff --git a/cmd/soroban-cli/src/print.rs b/cmd/soroban-cli/src/print.rs index 995a45a78..e2a1a8886 100644 --- a/cmd/soroban-cli/src/print.rs +++ b/cmd/soroban-cli/src/print.rs @@ -160,6 +160,7 @@ create_print_functions!(event, eventln, "📅"); create_print_functions!(blank, blankln, " "); create_print_functions!(gear, gearln, "⚙️"); create_print_functions!(dir, dirln, "📁"); +create_print_functions!(question, questionln, "❓"); #[cfg(test)] mod tests { From a5c04fc40f3b96c67684c95c1c79acdfeb74032a Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 17:45:03 -0700 Subject: [PATCH 07/20] Respect --verbose and --quiet on contract verify. --- cmd/soroban-cli/src/commands/contract/verify.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index cd789cd85..f3c618261 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -224,8 +224,8 @@ pub struct ExtractedMetadata { } impl Cmd { - pub async fn run(&self, _global_args: &global::Args) -> Result<(), Error> { - let print = Print::new(false); + pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { + let print = Print::new(global_args.quiet); let wasm_bytes = self.fetch_wasm().await?; let meta = extract_metadata(&wasm_bytes)?; @@ -280,7 +280,7 @@ impl Cmd { &[], &docker, &print, - false, + global_args.verbose || global_args.very_verbose, ) .await?; @@ -292,11 +292,13 @@ impl Cmd { source: e, })?; - // Compare. + // Compare. The final result is always shown, even under `--quiet`, + // via a dedicated Print that ignores the quiet flag. + let result_print = Print::new(false); let original_hash = format!("{:x}", Sha256::digest(&wasm_bytes)); let rebuilt_hash = format!("{:x}", Sha256::digest(&rebuilt)); if original_hash == rebuilt_hash && wasm_bytes.len() == rebuilt.len() { - print.checkln(format!( + result_print.checkln(format!( "verified: {} bytes, sha256={original_hash}", wasm_bytes.len() )); From d8f2937b441897ebb326a3a672ec549e77548722 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 17:47:42 -0700 Subject: [PATCH 08/20] Force trust prompts visible even under --quiet. --- cmd/soroban-cli/src/commands/contract/verify.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index f3c618261..b5121e35f 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -427,12 +427,15 @@ fn require_trust( value: value.to_string(), }); } - confirm_interactively(kind, value, print) + confirm_interactively(kind, value) } } } -fn confirm_interactively(kind: TrustKind, value: &str, print: &Print) -> Result<(), Error> { +fn confirm_interactively(kind: TrustKind, value: &str) -> Result<(), Error> { + // Trust prompts must be visible even under `--quiet` so the user can see + // what they're agreeing to. Use a dedicated Print that ignores the flag. + let print = Print::new(false); let context = match kind { TrustKind::Bldimg => format!( "Image {value} is not in the default trust list (only docker.io/stellar/stellar-cli is trusted by default)." From 56bfa5afc4011c6a3deed1328ea394e99665cd08 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 17:48:32 -0700 Subject: [PATCH 09/20] Capitalize Verified result line. --- cmd/soroban-cli/src/commands/contract/verify.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index b5121e35f..ce13d8bb9 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -299,7 +299,7 @@ impl Cmd { let rebuilt_hash = format!("{:x}", Sha256::digest(&rebuilt)); if original_hash == rebuilt_hash && wasm_bytes.len() == rebuilt.len() { result_print.checkln(format!( - "verified: {} bytes, sha256={original_hash}", + "Verified: {} bytes, sha256={original_hash}", wasm_bytes.len() )); Ok(()) From f1b1f1a427bf3bf4f7418336cd6a7a12a61e97ae Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 19:43:08 -0700 Subject: [PATCH 10/20] Capitalize info, warn, and check messages on contract verify. --- cmd/soroban-cli/src/commands/contract/verify.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index ce13d8bb9..4e0139b65 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -230,18 +230,16 @@ impl Cmd { let wasm_bytes = self.fetch_wasm().await?; let meta = extract_metadata(&wasm_bytes)?; - print.infoln(format!("bldimg: {}", meta.bldimg)); - + print.infoln(format!("Build image: {}", meta.bldimg)); if let Some(v) = &meta.source_uri { - print.infoln(format!("source_uri: {v}")); + print.infoln(format!("Source URI: {v}")); } - if let Some(v) = &meta.source_sha256 { - print.infoln(format!("source_sha256: {v}")); + print.infoln(format!("Source SHA-256: {v}")); } if !meta.bldopts.is_empty() { - print.infoln(format!("bldopt entries ({}):", meta.bldopts.len())); + print.infoln(format!("Build options ({}):", meta.bldopts.len())); for o in &meta.bldopts { print.blankln(format!(" • {o}")); } @@ -416,7 +414,7 @@ fn require_trust( TrustDecision::Trusted => Ok(()), TrustDecision::Overridden => { print.warnln(format!( - "trusting {kind} {value} because --trust was passed" + "Trusting {kind} {value} because --trust was passed" )); Ok(()) } @@ -491,10 +489,9 @@ async fn materialize_source( print.infoln(format!("Fetching source code from {source}")); let bytes = fetch_tarball_bytes(&source).await?; - if let Some(expected) = &meta.source_sha256 { verify_source_sha256(&bytes, expected)?; - print.checkln("source code sha256 matches"); + print.checkln("Source SHA-256 matches"); } extract_tarball(&bytes, target)?; Ok(()) From 9cb9b16cbd8018f92f55efe6fce9b305cae2d6fa Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 20:21:48 -0700 Subject: [PATCH 11/20] Expand github:user/repo source_repo before git clone. --- .../src/commands/contract/verify.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index 4e0139b65..92f9b2df8 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -925,6 +925,37 @@ mod tests { assert!(matches!(err, Error::SourceUriRequired)); } + #[test] + fn expand_source_repo_rewrites_github_shorthand() { + assert_eq!( + expand_source_repo("github:foo/bar"), + "https://github.com/foo/bar" + ); + } + + #[test] + fn expand_source_repo_passes_through_https() { + assert_eq!( + expand_source_repo("https://github.com/foo/bar"), + "https://github.com/foo/bar" + ); + assert_eq!( + expand_source_repo("https://gitlab.com/foo/bar.git"), + "https://gitlab.com/foo/bar.git" + ); + } + + #[test] + fn expand_source_repo_does_not_expand_malformed_github() { + // Missing the `/repo` suffix; the regex won't match so we pass through. + assert_eq!(expand_source_repo("github:foo"), "github:foo"); + // Extra path component; same. + assert_eq!( + expand_source_repo("github:foo/bar/baz"), + "github:foo/bar/baz" + ); + } + #[test] fn build_container_command_replays_bldopts_and_re_records_meta() { let meta = ExtractedMetadata { From 2faa4c291b044e5d9f720cca0cb002abee8ebe84 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 20:22:45 -0700 Subject: [PATCH 12/20] Validate retrieval channel before trust prompts. --- cmd/soroban-cli/src/commands/contract/verify.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index 92f9b2df8..a21de6830 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -245,6 +245,15 @@ impl Cmd { } } + // Catch the no-retrieval-channel case before any trust prompts so a + // doomed run errors immediately instead of asking the user to trust + // an image we won't end up using. + let has_git_source = meta.source_repo.is_some() && meta.source_rev.is_some(); + let has_tarball_url = self.tarball_url.is_some() || meta.tarball_url.is_some(); + if !has_git_source && !has_tarball_url { + return Err(Error::TarballUrlRequired); + } + // bldimg trust check is always required. require_trust(self.trust, TrustKind::Bldimg, &meta.bldimg, &print)?; From 55e420148f802f86e29a5de2be664fcf4c2d4fbc Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 22 May 2026 00:04:09 -0700 Subject: [PATCH 13/20] Anchor verify rebuilt-wasm search at manifest-path parent. --- .../src/commands/contract/verify.rs | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index a21de6830..14361436f 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -608,9 +608,23 @@ fn find_rebuilt_wasm(workdir: &Path, meta: &ExtractedMetadata) -> Result = Vec::new(); @@ -647,11 +661,11 @@ fn find_rebuilt_wasm(workdir: &Path, meta: &ExtractedMetadata) -> Result Err(Error::NoRebuiltWasm { - target: workdir.join("target"), + target: target_base.join("target"), }), 1 => Ok(found.into_iter().next().unwrap()), _ => Err(Error::AmbiguousRebuiltWasm { - target: workdir.join("target"), + target: target_base.join("target"), found: found .iter() .map(|p| p.display().to_string()) From ec95d1a013254dcd40daad4c6dab34b8d8127505 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 22 May 2026 00:04:39 -0700 Subject: [PATCH 14/20] Add stellar contract verify integration tests. --- cmd/crates/soroban-test/tests/it/main.rs | 1 + cmd/crates/soroban-test/tests/it/verify.rs | 190 +++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 cmd/crates/soroban-test/tests/it/verify.rs diff --git a/cmd/crates/soroban-test/tests/it/main.rs b/cmd/crates/soroban-test/tests/it/main.rs index e96558af5..c9f3ae8ca 100644 --- a/cmd/crates/soroban-test/tests/it/main.rs +++ b/cmd/crates/soroban-test/tests/it/main.rs @@ -12,4 +12,5 @@ mod plugin; mod rpc_provider; mod strkey; mod util; +mod verify; mod version; diff --git a/cmd/crates/soroban-test/tests/it/verify.rs b/cmd/crates/soroban-test/tests/it/verify.rs new file mode 100644 index 000000000..a9a14ec4e --- /dev/null +++ b/cmd/crates/soroban-test/tests/it/verify.rs @@ -0,0 +1,190 @@ +//! End-to-end tests for `stellar contract verify`. +//! +//! These exercise the full pipeline: build a contract verifiably against a +//! pinned bldimg + pinned source_repo, then verify the resulting wasm matches. +//! The "happy path" tests require docker + network access to GitHub + the +//! pinned bldimg pullable from Docker Hub. They are always-run by convention +//! (per the project's "no #[ignore]" rule) — failures there flag a regression +//! or pinned-resource drift loudly. +//! +//! Fixture pins: +//! - bldimg: `docker.io/fnando/stellar-cli-experimental@sha256:85e76e…`. +//! TODO: swap to `docker.io/stellar/stellar-cli@sha256:<…>` once +//! `stellar/stellar-cli-docker` publishes a canonical tag matching the +//! cli version under test. +//! - source_repo + source_rev: a specific commit on +//! `stellar/soroban-examples`. The `hello_world` contract there is the +//! smallest, most-stable example; we build just that with `--package`. + +use gix::progress::Discard; +use predicates::prelude::{predicate, PredicateBooleanExt}; +use soroban_test::TestEnv; +use std::path::PathBuf; +use std::sync::atomic::AtomicBool; + +const PINNED_BLDIMG: &str = + "docker.io/fnando/stellar-cli-experimental@sha256:85e76eae8bf9f47ba94391214b76f8fa2b9d7b28171774dfafaf5b8d613a74d3"; +const PINNED_SOURCE_REPO: &str = "github:stellar/soroban-examples"; +const PINNED_SOURCE_REV: &str = "7b168174ae1268dab91a0190d80a94ab7ff41b59"; +/// `soroban-examples` has no root `Cargo.toml` — each example is its own +/// crate in a subdirectory. The cli's source-root resolver anchors the +/// bind-mount + the recorded bldopt to the clone root, so the manifest-path +/// stays portable as `hello_world/Cargo.toml` regardless of where the user +/// invoked from. +const PINNED_MANIFEST_PATH: &str = "hello_world/Cargo.toml"; + +/// Build a verifiable wasm for the pinned hello-world example and write it to +/// `/out/soroban_hello_world_contract.wasm`. Returns the on-disk path. +fn build_verifiable_hello_world(sandbox: &TestEnv) -> PathBuf { + let out_dir = sandbox.dir().join("out"); + std::fs::create_dir_all(&out_dir).unwrap(); + sandbox + .new_assert_cmd("contract") + .arg("build") + .arg("--verifiable") + .arg("--image") + .arg(PINNED_BLDIMG) + .arg("--source-repo") + .arg(PINNED_SOURCE_REPO) + .arg("--source-rev") + .arg(PINNED_SOURCE_REV) + .arg("--manifest-path") + .arg(PINNED_MANIFEST_PATH) + .arg("--out-dir") + .arg(&out_dir) + .current_dir(prepared_source_tree(sandbox)) + .assert() + .success(); + out_dir.join("soroban_hello_world_contract.wasm") +} + +/// Materialize the pinned `stellar/soroban-examples` source tree at `/soroban-examples` +/// so the verifiable build has a workspace_root to bind-mount into the +/// container. The host's source tree is what the bldimg actually compiles; +/// `source_repo` + `source_rev` recorded into the wasm only tell a future +/// verifier where to fetch from. We clone via gix to stay shell-free. +fn prepared_source_tree(sandbox: &TestEnv) -> PathBuf { + let dir = sandbox.dir().join("soroban-examples"); + if dir.exists() { + return dir; + } + // Mirror what the cli's `verify::clone_git_source` does — same gix call + // sequence, same flags — so the test exercises the production code path + // a third-party verifier would hit. + let interrupt = AtomicBool::new(false); + let mut prepare = gix::prepare_clone_bare("https://github.com/stellar/soroban-examples", &dir) + .expect("prepare_clone_bare"); + let (repo, _) = prepare.fetch_only(Discard, &interrupt).expect("fetch_only"); + let oid = gix::ObjectId::from_hex(PINNED_SOURCE_REV.as_bytes()).expect("rev hex"); + let object = repo.find_object(oid).expect("find_object"); + let commit = object.peel_to_commit().expect("peel_to_commit"); + let tree_id = commit.tree_id().expect("tree_id"); + let index = gix::index::State::from_tree( + &tree_id, + &repo.objects, + gix::validate::path::component::Options::default(), + ) + .expect("from_tree"); + let mut index_file = gix::index::File::from_state(index, dir.join(".git").join("index")); + gix::worktree::state::checkout( + &mut index_file, + &dir, + repo.objects.clone().into_arc().expect("into_arc"), + &Discard, + &Discard, + &interrupt, + gix::worktree::state::checkout::Options { + destination_is_initially_empty: true, + overwrite_existing: true, + ..Default::default() + }, + ) + .expect("checkout"); + dir +} + +/// Happy path: build a verifiable wasm, then verify it from the local file. +/// Asserts the cli prints `Verified:` on stdout (or stderr; we accept either +/// via `predicates`). +#[test] +fn verify_wasm_succeeds_for_freshly_built_verifiable_wasm() { + let sandbox = TestEnv::default(); + let wasm = build_verifiable_hello_world(&sandbox); + + sandbox + .new_assert_cmd("contract") + .arg("verify") + .arg("--wasm") + .arg(&wasm) + .arg("--trust") + .assert() + .success() + .stderr(predicate::str::contains("Verified:")); +} + +/// Build verifiable → upload to local network → verify by --id. Exercises +/// the wasm::fetch_from_contract path through the verify command. +#[tokio::test] +async fn verify_id_succeeds_after_upload() { + let sandbox = TestEnv::new(); + let wasm = build_verifiable_hello_world(&sandbox); + let wasm_str = wasm.to_string_lossy().to_string(); + + // Upload (cheaper than full deploy; verify only needs the wasm bytes, which + // upload puts on-ledger under a known hash). `--id` accepts a contract id + // OR an alias OR (via wasm_hash) any thing the network can resolve to wasm. + // The deploy path is what gives us a contract id we can pass to --id. + let id = sandbox + .new_assert_cmd("contract") + .arg("deploy") + .arg("--wasm") + .arg(&wasm_str) + .arg("--alias") + .arg("verify_e2e") + .arg("--ignore-checks") + .assert() + .success() + .stdout(predicate::str::is_empty().not()) + .get_output() + .stdout + .clone(); + let id = String::from_utf8(id).unwrap().trim().to_string(); + + sandbox + .new_assert_cmd("contract") + .arg("verify") + .arg("--id") + .arg(&id) + .arg("--trust") + .assert() + .success() + .stderr(predicate::str::contains("Verified:")); +} + +/// Flip a byte in a verifiable wasm and confirm `contract verify` reports the +/// mismatch (different hashes). +#[test] +fn verify_wasm_fails_on_tampered_bytes() { + let sandbox = TestEnv::default(); + let wasm = build_verifiable_hello_world(&sandbox); + + // Tamper: corrupt a byte somewhere in the middle of the WASM. The custom + // section that holds contractmetav0 is near the end; flipping a code byte + // changes the bytes-under-comparison without invalidating the WASM enough + // to break the cli's metadata parse. + let mut bytes = std::fs::read(&wasm).unwrap(); + let mid = bytes.len() / 2; + bytes[mid] = bytes[mid].wrapping_add(1); + let tampered = sandbox.dir().join("tampered.wasm"); + std::fs::write(&tampered, &bytes).unwrap(); + + sandbox + .new_assert_cmd("contract") + .arg("verify") + .arg("--wasm") + .arg(&tampered) + .arg("--trust") + .assert() + .failure() + .stderr(predicate::str::contains("verification failed")); +} From 96c463a71bacb81d64ab73b7e0f28861af3e0849 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 22 May 2026 00:22:10 -0700 Subject: [PATCH 15/20] Add tarball-sha256 + local --tarball-url verify test. --- Cargo.toml | 2 + cmd/crates/soroban-test/Cargo.toml | 2 + cmd/crates/soroban-test/tests/it/verify.rs | 163 ++++++++---------- cmd/soroban-cli/Cargo.toml | 4 +- .../src/commands/contract/verify.rs | 40 +---- 5 files changed, 80 insertions(+), 131 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 90664eb5d..51154b3fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,8 @@ hex = "0.4.3" itertools = "0.10.0" async-trait = "0.1.76" bollard = "0.20.2" +tar = "0.4.46" +flate2 = "1.0.30" serde-aux = "4.1.2" serde_json = "1.0.82" serde = "1.0.82" diff --git a/cmd/crates/soroban-test/Cargo.toml b/cmd/crates/soroban-test/Cargo.toml index f89fc7066..d7226fcf4 100644 --- a/cmd/crates/soroban-test/Cargo.toml +++ b/cmd/crates/soroban-test/Cargo.toml @@ -55,6 +55,8 @@ tracing = "0.1.40" tracing-subscriber = "0.3.18" httpmock = { workspace = true } reqwest = { workspace = true } +tar = { workspace = true } +flate2 = { workspace = true } [features] default = [] diff --git a/cmd/crates/soroban-test/tests/it/verify.rs b/cmd/crates/soroban-test/tests/it/verify.rs index a9a14ec4e..3337db995 100644 --- a/cmd/crates/soroban-test/tests/it/verify.rs +++ b/cmd/crates/soroban-test/tests/it/verify.rs @@ -1,41 +1,54 @@ //! End-to-end tests for `stellar contract verify`. //! -//! These exercise the full pipeline: build a contract verifiably against a -//! pinned bldimg + pinned source_repo, then verify the resulting wasm matches. -//! The "happy path" tests require docker + network access to GitHub + the -//! pinned bldimg pullable from Docker Hub. They are always-run by convention -//! (per the project's "no #[ignore]" rule) — failures there flag a regression -//! or pinned-resource drift loudly. +//! Pipeline, entirely through the cli (no git/network clone needed): +//! 1. `contract init` scaffolds a workspace + `hello-world` contract. +//! 2. `contract build --verifiable` builds it against a pinned bldimg and +//! records `source_sha256` in the wasm's SEP-58 metadata. +//! 3. `contract archive` regenerates the *same* source tarball (same +//! `build_source_archive` the verifiable build used), so its sha256 matches +//! the recorded `source_sha256`. +//! 4. `contract verify --source-uri ` materializes the source, +//! rebuilds in the bldimg, and byte-compares. //! -//! Fixture pins: +//! The happy-path tests require docker + the pinned bldimg pullable from Docker +//! Hub. They are always-run by convention (per the project's "no #[ignore]" +//! rule) — failures there flag a regression or pinned-resource drift loudly. +//! +//! Fixture pin: //! - bldimg: `docker.io/fnando/stellar-cli-experimental@sha256:85e76e…`. //! TODO: swap to `docker.io/stellar/stellar-cli@sha256:<…>` once //! `stellar/stellar-cli-docker` publishes a canonical tag matching the //! cli version under test. -//! - source_repo + source_rev: a specific commit on -//! `stellar/soroban-examples`. The `hello_world` contract there is the -//! smallest, most-stable example; we build just that with `--package`. -use gix::progress::Discard; use predicates::prelude::{predicate, PredicateBooleanExt}; use soroban_test::TestEnv; -use std::path::PathBuf; -use std::sync::atomic::AtomicBool; +use std::path::{Path, PathBuf}; const PINNED_BLDIMG: &str = "docker.io/fnando/stellar-cli-experimental@sha256:85e76eae8bf9f47ba94391214b76f8fa2b9d7b28171774dfafaf5b8d613a74d3"; -const PINNED_SOURCE_REPO: &str = "github:stellar/soroban-examples"; -const PINNED_SOURCE_REV: &str = "7b168174ae1268dab91a0190d80a94ab7ff41b59"; -/// `soroban-examples` has no root `Cargo.toml` — each example is its own -/// crate in a subdirectory. The cli's source-root resolver anchors the -/// bind-mount + the recorded bldopt to the clone root, so the manifest-path -/// stays portable as `hello_world/Cargo.toml` regardless of where the user -/// invoked from. -const PINNED_MANIFEST_PATH: &str = "hello_world/Cargo.toml"; -/// Build a verifiable wasm for the pinned hello-world example and write it to -/// `/out/soroban_hello_world_contract.wasm`. Returns the on-disk path. -fn build_verifiable_hello_world(sandbox: &TestEnv) -> PathBuf { +/// Scaffold a workspace with the default `hello-world` contract under +/// `/proj`. The scaffolded tree is not a git repo, so the verifiable +/// build archives the working directory directly. +fn init_project(sandbox: &TestEnv) -> PathBuf { + let proj = sandbox.dir().join("proj"); + sandbox + .new_assert_cmd("contract") + .arg("init") + .arg(&proj) + .assert() + .success(); + proj +} + +/// Build the scaffolded contract verifiably and generate the matching source +/// archive. Returns `(wasm_path, archive_path)`. +/// +/// The archive is produced *after* the verifiable build on purpose: the build's +/// host-side `cargo metadata` writes `Cargo.lock` into the workspace, and +/// `contract archive` then captures that same tree — so the archive's sha256 +/// equals the `source_sha256` the build recorded into the wasm. +fn build_and_archive(sandbox: &TestEnv, proj: &Path) -> (PathBuf, PathBuf) { let out_dir = sandbox.dir().join("out"); std::fs::create_dir_all(&out_dir).unwrap(); sandbox @@ -44,96 +57,57 @@ fn build_verifiable_hello_world(sandbox: &TestEnv) -> PathBuf { .arg("--verifiable") .arg("--image") .arg(PINNED_BLDIMG) - .arg("--source-repo") - .arg(PINNED_SOURCE_REPO) - .arg("--source-rev") - .arg(PINNED_SOURCE_REV) - .arg("--manifest-path") - .arg(PINNED_MANIFEST_PATH) .arg("--out-dir") .arg(&out_dir) - .current_dir(prepared_source_tree(sandbox)) + .current_dir(proj) + .assert() + .success(); + + let archive = sandbox.dir().join("source.tar.gz"); + sandbox + .new_assert_cmd("contract") + .arg("archive") + .arg("--out-file") + .arg(&archive) + .current_dir(proj) .assert() .success(); - out_dir.join("soroban_hello_world_contract.wasm") -} -/// Materialize the pinned `stellar/soroban-examples` source tree at `/soroban-examples` -/// so the verifiable build has a workspace_root to bind-mount into the -/// container. The host's source tree is what the bldimg actually compiles; -/// `source_repo` + `source_rev` recorded into the wasm only tell a future -/// verifier where to fetch from. We clone via gix to stay shell-free. -fn prepared_source_tree(sandbox: &TestEnv) -> PathBuf { - let dir = sandbox.dir().join("soroban-examples"); - if dir.exists() { - return dir; - } - // Mirror what the cli's `verify::clone_git_source` does — same gix call - // sequence, same flags — so the test exercises the production code path - // a third-party verifier would hit. - let interrupt = AtomicBool::new(false); - let mut prepare = gix::prepare_clone_bare("https://github.com/stellar/soroban-examples", &dir) - .expect("prepare_clone_bare"); - let (repo, _) = prepare.fetch_only(Discard, &interrupt).expect("fetch_only"); - let oid = gix::ObjectId::from_hex(PINNED_SOURCE_REV.as_bytes()).expect("rev hex"); - let object = repo.find_object(oid).expect("find_object"); - let commit = object.peel_to_commit().expect("peel_to_commit"); - let tree_id = commit.tree_id().expect("tree_id"); - let index = gix::index::State::from_tree( - &tree_id, - &repo.objects, - gix::validate::path::component::Options::default(), - ) - .expect("from_tree"); - let mut index_file = gix::index::File::from_state(index, dir.join(".git").join("index")); - gix::worktree::state::checkout( - &mut index_file, - &dir, - repo.objects.clone().into_arc().expect("into_arc"), - &Discard, - &Discard, - &interrupt, - gix::worktree::state::checkout::Options { - destination_is_initially_empty: true, - overwrite_existing: true, - ..Default::default() - }, - ) - .expect("checkout"); - dir + (out_dir.join("hello_world.wasm"), archive) } -/// Happy path: build a verifiable wasm, then verify it from the local file. -/// Asserts the cli prints `Verified:` on stdout (or stderr; we accept either -/// via `predicates`). +/// Happy path: build a verifiable wasm, then verify it from the local file, +/// handing the cli the matching source archive via `--source-uri`. Asserts the +/// cli prints `Verified:` on stderr. #[test] fn verify_wasm_succeeds_for_freshly_built_verifiable_wasm() { let sandbox = TestEnv::default(); - let wasm = build_verifiable_hello_world(&sandbox); + let proj = init_project(&sandbox); + let (wasm, archive) = build_and_archive(&sandbox, &proj); sandbox .new_assert_cmd("contract") .arg("verify") .arg("--wasm") .arg(&wasm) + .arg("--source-uri") + .arg(&archive) .arg("--trust") .assert() .success() .stderr(predicate::str::contains("Verified:")); } -/// Build verifiable → upload to local network → verify by --id. Exercises -/// the wasm::fetch_from_contract path through the verify command. +/// Build verifiable → upload to local network → verify by `--id`. Exercises the +/// `wasm::fetch_from_contract` path through the verify command. #[tokio::test] async fn verify_id_succeeds_after_upload() { let sandbox = TestEnv::new(); - let wasm = build_verifiable_hello_world(&sandbox); + let proj = init_project(&sandbox); + let (wasm, archive) = build_and_archive(&sandbox, &proj); let wasm_str = wasm.to_string_lossy().to_string(); - // Upload (cheaper than full deploy; verify only needs the wasm bytes, which - // upload puts on-ledger under a known hash). `--id` accepts a contract id - // OR an alias OR (via wasm_hash) any thing the network can resolve to wasm. - // The deploy path is what gives us a contract id we can pass to --id. + // Deploy gives us a contract id `--id` can resolve to the on-ledger wasm. let id = sandbox .new_assert_cmd("contract") .arg("deploy") @@ -155,6 +129,8 @@ async fn verify_id_succeeds_after_upload() { .arg("verify") .arg("--id") .arg(&id) + .arg("--source-uri") + .arg(&archive) .arg("--trust") .assert() .success() @@ -162,16 +138,15 @@ async fn verify_id_succeeds_after_upload() { } /// Flip a byte in a verifiable wasm and confirm `contract verify` reports the -/// mismatch (different hashes). +/// mismatch. The flipped byte is in the middle (code) so the trailing +/// `contractmetav0` section still parses; the rebuild reproduces the original +/// bytes, and the byte comparison fails. #[test] fn verify_wasm_fails_on_tampered_bytes() { let sandbox = TestEnv::default(); - let wasm = build_verifiable_hello_world(&sandbox); + let proj = init_project(&sandbox); + let (wasm, archive) = build_and_archive(&sandbox, &proj); - // Tamper: corrupt a byte somewhere in the middle of the WASM. The custom - // section that holds contractmetav0 is near the end; flipping a code byte - // changes the bytes-under-comparison without invalidating the WASM enough - // to break the cli's metadata parse. let mut bytes = std::fs::read(&wasm).unwrap(); let mid = bytes.len() / 2; bytes[mid] = bytes[mid].wrapping_add(1); @@ -183,6 +158,8 @@ fn verify_wasm_fails_on_tampered_bytes() { .arg("verify") .arg("--wasm") .arg(&tampered) + .arg("--source-uri") + .arg(&archive) .arg("--trust") .assert() .failure() diff --git a/cmd/soroban-cli/Cargo.toml b/cmd/soroban-cli/Cargo.toml index 5648ecf13..0ba4f7ffe 100644 --- a/cmd/soroban-cli/Cargo.toml +++ b/cmd/soroban-cli/Cargo.toml @@ -111,8 +111,8 @@ bollard = { workspace = true } futures-util = "0.3.30" futures = "0.3.30" home = "0.5.9" -flate2 = "1.0.30" -tar = "0.4.46" +flate2 = { workspace = true } +tar = { workspace = true } bytesize = "1.3.0" humantime = "2.1.0" phf = { version = "0.11.2", features = ["macros"] } diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index 14361436f..324f0968d 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -247,11 +247,10 @@ impl Cmd { // Catch the no-retrieval-channel case before any trust prompts so a // doomed run errors immediately instead of asking the user to trust - // an image we won't end up using. - let has_git_source = meta.source_repo.is_some() && meta.source_rev.is_some(); - let has_tarball_url = self.tarball_url.is_some() || meta.tarball_url.is_some(); - if !has_git_source && !has_tarball_url { - return Err(Error::TarballUrlRequired); + // an image we won't end up using. With only `source_sha256` recorded + // and no `--source-uri` override, there's nowhere to fetch from. + if self.effective_source_uri(&meta).is_none() { + return Err(Error::SourceUriRequired); } // bldimg trust check is always required. @@ -948,37 +947,6 @@ mod tests { assert!(matches!(err, Error::SourceUriRequired)); } - #[test] - fn expand_source_repo_rewrites_github_shorthand() { - assert_eq!( - expand_source_repo("github:foo/bar"), - "https://github.com/foo/bar" - ); - } - - #[test] - fn expand_source_repo_passes_through_https() { - assert_eq!( - expand_source_repo("https://github.com/foo/bar"), - "https://github.com/foo/bar" - ); - assert_eq!( - expand_source_repo("https://gitlab.com/foo/bar.git"), - "https://gitlab.com/foo/bar.git" - ); - } - - #[test] - fn expand_source_repo_does_not_expand_malformed_github() { - // Missing the `/repo` suffix; the regex won't match so we pass through. - assert_eq!(expand_source_repo("github:foo"), "github:foo"); - // Extra path component; same. - assert_eq!( - expand_source_repo("github:foo/bar/baz"), - "github:foo/bar/baz" - ); - } - #[test] fn build_container_command_replays_bldopts_and_re_records_meta() { let meta = ExtractedMetadata { From 79dc60e929cca1868fe25400d553216d35335052 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 22 May 2026 00:29:59 -0700 Subject: [PATCH 16/20] Move verify integration tests into integration tier. --- cmd/crates/soroban-test/tests/it/integration/contract/mod.rs | 1 + .../soroban-test/tests/it/{ => integration/contract}/verify.rs | 0 cmd/crates/soroban-test/tests/it/main.rs | 1 - 3 files changed, 1 insertion(+), 1 deletion(-) rename cmd/crates/soroban-test/tests/it/{ => integration/contract}/verify.rs (100%) diff --git a/cmd/crates/soroban-test/tests/it/integration/contract/mod.rs b/cmd/crates/soroban-test/tests/it/integration/contract/mod.rs index 5e5f4b7a0..a8ba22da2 100644 --- a/cmd/crates/soroban-test/tests/it/integration/contract/mod.rs +++ b/cmd/crates/soroban-test/tests/it/integration/contract/mod.rs @@ -1,2 +1,3 @@ mod fetch; mod info_hash; +mod verify; diff --git a/cmd/crates/soroban-test/tests/it/verify.rs b/cmd/crates/soroban-test/tests/it/integration/contract/verify.rs similarity index 100% rename from cmd/crates/soroban-test/tests/it/verify.rs rename to cmd/crates/soroban-test/tests/it/integration/contract/verify.rs diff --git a/cmd/crates/soroban-test/tests/it/main.rs b/cmd/crates/soroban-test/tests/it/main.rs index c9f3ae8ca..e96558af5 100644 --- a/cmd/crates/soroban-test/tests/it/main.rs +++ b/cmd/crates/soroban-test/tests/it/main.rs @@ -12,5 +12,4 @@ mod plugin; mod rpc_provider; mod strkey; mod util; -mod verify; mod version; From 7fb44cab89fc71f435e3c83cdacb210b4aac2c90 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 22 May 2026 01:24:02 -0700 Subject: [PATCH 17/20] Restrict permissions on materialized verify source. --- cmd/soroban-cli/src/commands/contract/verify.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index 324f0968d..1c0c8d3b4 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -125,6 +125,12 @@ pub enum Error { #[error("creating tempdir: {0}")] TempDir(std::io::Error), + #[error("hardening permissions on {path}: {source}")] + ChmodMaterialized { + path: PathBuf, + source: std::io::Error, + }, + #[error(transparent)] Verifiable(#[from] verifiable::Error), @@ -502,6 +508,16 @@ async fn materialize_source( print.checkln("Source SHA-256 matches"); } extract_tarball(&bytes, target)?; + + // Tighten the freshly materialized tree to 0o700 / 0o600 before docker + // sees it. Uses the same per-path helper the cli already applies to its + // config dirs (one source of truth for what "hardened" means). + crate::config::locator::enforce_hardened_tree(target).map_err(|e| { + Error::ChmodMaterialized { + path: target.to_path_buf(), + source: e, + } + })?; Ok(()) } From a550f47dbf591be36519a5a682ed1f5bf02b5699 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 17 Jun 2026 15:28:12 -0700 Subject: [PATCH 18/20] Share build logic and fix contract verify rebuild. --- cmd/crates/soroban-test/Cargo.toml | 2 - .../commands/contract/build/source_archive.rs | 35 +++ .../src/commands/contract/build/verifiable.rs | 32 +-- .../src/commands/contract/verify.rs | 226 +++++++++--------- 4 files changed, 154 insertions(+), 141 deletions(-) diff --git a/cmd/crates/soroban-test/Cargo.toml b/cmd/crates/soroban-test/Cargo.toml index d7226fcf4..f89fc7066 100644 --- a/cmd/crates/soroban-test/Cargo.toml +++ b/cmd/crates/soroban-test/Cargo.toml @@ -55,8 +55,6 @@ tracing = "0.1.40" tracing-subscriber = "0.3.18" httpmock = { workspace = true } reqwest = { workspace = true } -tar = { workspace = true } -flate2 = { workspace = true } [features] default = [] diff --git a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs index 2f20d516c..88a046503 100644 --- a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs +++ b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs @@ -19,6 +19,7 @@ use std::{ use walkdir::WalkDir; +use crate::config::{data, locator::enforce_hardened_tree}; use crate::print::Print; /// Top-level names excluded when archiving a non-git working directory (we have @@ -73,6 +74,9 @@ pub enum Error { #[error("could not extract source archive: {0}")] ArchiveExtract(std::io::Error), + + #[error(transparent)] + Data(#[from] data::Error), } /// Pick the anchor for the source tree: the directory whose `.git` parent we @@ -290,6 +294,37 @@ pub(crate) fn unpack_targz(bytes: &[u8], dest: &Path) -> Result<(), Error> { .map_err(Error::ArchiveExtract) } +/// Create a fresh temp directory, unpack the gzipped source tarball `bytes` into +/// it, harden its permissions, and return the guard (the tree lives at its +/// `path()`). Shared by `build --verifiable` (builds from the extracted copy) +/// and `verify` (rebuilds from it); `prefix` names the dir so the two are +/// distinguishable on disk. +/// +/// The temp dir is created under `/tmp`, NOT the OS temp dir: on macOS +/// `$TMPDIR` lives under /var/folders, which container VMs (Docker Desktop, +/// Colima, …) don't share by default, so a bind mount of it would be empty +/// inside the container. The data dir lives under the user's home, which is +/// shared. Corralling every extraction under a single `tmp/` keeps a leftover +/// from an interrupted run isolated in one obviously-disposable place rather +/// than loose alongside `archives/`. +pub(crate) fn extract_into_hardened_tempdir( + bytes: &[u8], + prefix: &str, +) -> Result { + let base = data::data_local_dir()?.join("tmp"); + std::fs::create_dir_all(&base).map_err(|source| Error::ArchiveWrite { + path: base.clone(), + source, + })?; + let tmp = tempfile::Builder::new() + .prefix(prefix) + .tempdir_in(&base) + .map_err(Error::ArchiveExtract)?; + unpack_targz(bytes, tmp.path())?; + enforce_hardened_tree(tmp.path()).map_err(Error::ArchiveExtract)?; + Ok(tmp) +} + #[cfg(test)] mod tests { use super::*; diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 3cf56e1c0..fca1873f9 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -18,7 +18,7 @@ use sha2::{Digest, Sha256}; use crate::{ commands::{container::shared::Error as ConnectionError, global}, - config::{data, locator::enforce_hardened_tree}, + config::data, print::Print, }; @@ -252,9 +252,9 @@ fn resolve_workspace_root(cmd: &Cmd) -> Result { /// `--source-sha256` or computed from the generated archive). `source_uri` is /// `Some` only when the user passed `--source-uri`. #[derive(Debug, Default, Clone)] -struct SourceIds { - source_uri: Option, - source_sha256: Option, +pub(crate) struct SourceIds { + pub(crate) source_uri: Option, + pub(crate) source_sha256: Option, } /// Format-validate the user-supplied source flags. Both are optional under @@ -319,23 +319,7 @@ fn resolve_archive(cmd: &Cmd, source_root: &Path, print: &Print) -> Result Vec { +pub(crate) fn build_metadata_args( + image_ref: &str, + ids: &SourceIds, + bldopts: &[String], +) -> Vec { let mut out = Vec::new(); let push = |out: &mut Vec, key: &str, val: &str| { diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index 1c0c8d3b4..680a3fd4e 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -10,8 +10,9 @@ use stellar_xdr::curr::{ScMetaEntry, ScMetaV0}; use crate::{ commands::{ container, - contract::build::verifiable::{ - self, bldimg_regex, source_sha256_regex, source_uri_regex, + contract::build::{ + source_archive, + verifiable::{self, bldimg_regex, source_sha256_regex, source_uri_regex}, }, global, }, @@ -116,20 +117,17 @@ pub enum Error { #[error("source code sha256 mismatch: expected {expected}, got {actual}")] SourceHashMismatch { expected: String, actual: String }, - #[error("extracting source code into {path}: {source}")] + #[error("reading extracted source at {path}: {source}")] SourceExtract { path: PathBuf, source: std::io::Error, }, - #[error("creating tempdir: {0}")] - TempDir(std::io::Error), + #[error("source archive at {path} does not contain exactly one top-level directory (found {count}); SEP-58 requires the source be wrapped in a single directory")] + SourceArchiveLayout { path: PathBuf, count: usize }, - #[error("hardening permissions on {path}: {source}")] - ChmodMaterialized { - path: PathBuf, - source: std::io::Error, - }, + #[error(transparent)] + SourceArchive(#[from] source_archive::Error), #[error(transparent)] Verifiable(#[from] verifiable::Error), @@ -269,10 +267,9 @@ impl Cmd { } // Materialize the recorded source into a tempdir so the rebuild can - // bind-mount it. TempDir lives across the rebuild + comparison and + // bind-mount it. The TempDir lives across the rebuild + comparison and // cleans up on drop. - let workdir = tempfile::TempDir::new().map_err(Error::TempDir)?; - materialize_source(&meta, self.source_uri.as_deref(), workdir.path(), &print).await?; + let workdir = materialize_source(&meta, self.source_uri.as_deref(), &print).await?; print.checkln(format!( "Source materialized at {}", workdir.path().display() @@ -284,12 +281,18 @@ impl Cmd { }; let docker = docker_args.connect_to_docker(&print).await?; verifiable::pull_image(&docker, &meta.bldimg, &print).await?; - let container_cmd = build_container_command(&meta); + let (container_cmd, env) = build_container_command(&meta); + + // SEP-58 requires the source be wrapped in a single top-level directory + // (the cli names it `source/`, but the spec doesn't fix the name), so + // the build's working tree is that wrapper dir under `workdir`. + let source_root = locate_extracted_source_root(workdir.path())?; + verifiable::run_in_container( &meta.bldimg, - workdir.path(), + &source_root, &[container_cmd], - &[], + &env, &docker, &print, global_args.verbose || global_args.very_verbose, @@ -297,8 +300,8 @@ impl Cmd { .await?; // Locate the rebuilt WASM. The cargo target dir lives under the bind- - // mounted /source, which we mapped to `workdir`. - let rebuilt_path = find_rebuilt_wasm(workdir.path(), &meta)?; + // mounted /source, which we mapped to `source_root`. + let rebuilt_path = find_rebuilt_wasm(&source_root, &meta)?; let rebuilt = std::fs::read(&rebuilt_path).map_err(|e| Error::ReadRebuilt { path: rebuilt_path.clone(), source: e, @@ -330,9 +333,7 @@ impl Cmd { /// records a `source_uri` (only `source_sha256` is set), in which case /// there's nothing to trust-check here. fn effective_source_uri(&self, meta: &ExtractedMetadata) -> Option { - self.source_uri - .clone() - .or_else(|| meta.source_uri.clone()) + self.source_uri.clone().or_else(|| meta.source_uri.clone()) } async fn fetch_wasm(&self) -> Result, Error> { @@ -477,22 +478,19 @@ pub fn parse_yes(answer: &str) -> bool { a.eq_ignore_ascii_case("y") || a.eq_ignore_ascii_case("yes") } -/// Materialize the recorded source tree into `target`. Picks the path based on -/// what the WASM recorded: -/// - source_uri (with optional sha256) → download/read, optional sha-check, -/// extract via `tar` -/// - source_sha256 only → require `--source-uri` on the cli and use it as -/// the retrieval channel +/// Materialize the recorded source tree into a fresh, permission-hardened +/// tempdir and return the guard. The retrieval channel is the cli's +/// `--source-uri` flag (when set) or the WASM's recorded `source_uri`; either +/// may be an http(s) URL or a local file path. When the bytes are present, the +/// optional `source_sha256` is checked before extraction. /// -/// `source_uri_override` is the cli's `--source-uri` flag value; when set, it -/// wins over whatever the WASM recorded, and may be an http(s) URL or a local -/// file path. +/// Extraction (under the data dir, hardened) is shared with `build +/// --verifiable` via `source_archive::extract_into_hardened_tempdir`. async fn materialize_source( meta: &ExtractedMetadata, source_uri_override: Option<&str>, - target: &Path, print: &Print, -) -> Result<(), Error> { +) -> Result { let tarball_source = source_uri_override .map(str::to_string) .or_else(|| meta.source_uri.clone()); @@ -507,18 +505,10 @@ async fn materialize_source( verify_source_sha256(&bytes, expected)?; print.checkln("Source SHA-256 matches"); } - extract_tarball(&bytes, target)?; - - // Tighten the freshly materialized tree to 0o700 / 0o600 before docker - // sees it. Uses the same per-path helper the cli already applies to its - // config dirs (one source of truth for what "hardened" means). - crate::config::locator::enforce_hardened_tree(target).map_err(|e| { - Error::ChmodMaterialized { - path: target.to_path_buf(), - source: e, - } - })?; - Ok(()) + Ok(source_archive::extract_into_hardened_tempdir( + &bytes, + "verify-src-", + )?) } /// Retrieve the tarball bytes. `source` is either an `http(s)://` URL or a @@ -565,44 +555,71 @@ fn verify_source_sha256(bytes: &[u8], expected: &str) -> Result<(), Error> { } } -fn extract_tarball(bytes: &[u8], target: &Path) -> Result<(), Error> { - let gz = flate2::read::GzDecoder::new(bytes); - let mut archive = tar::Archive::new(gz); - archive.unpack(target).map_err(|e| Error::SourceExtract { - path: target.to_path_buf(), - source: e, - }) +/// SEP-58 requires the source archive wrap everything in a single top-level +/// directory (the cli names it `source/`, but the spec leaves the name open), +/// so after extraction the build tree is that lone directory under `workdir`. +/// Return it, erroring if the archive doesn't have exactly one top-level dir. +fn locate_extracted_source_root(workdir: &Path) -> Result { + let mut dirs: Vec = std::fs::read_dir(workdir) + .map_err(|source| Error::SourceExtract { + path: workdir.to_path_buf(), + source, + })? + .filter_map(|entry| entry.ok().map(|e| e.path())) + .filter(|p| p.is_dir()) + .collect(); + + match dirs.len() { + 1 => Ok(dirs.remove(0)), + count => Err(Error::SourceArchiveLayout { + path: workdir.to_path_buf(), + count, + }), + } } -/// Compose the argv we hand to the container's `stellar contract build` so -/// that: +/// Compose the argv we hand to the container's `stellar contract build`, plus +/// the env vars to apply via docker `-e`, so that: /// - the bldopts from the original build become flags (each entry is one /// token, ready for clap), AND /// - bldimg / source-ids / bldopt are re-recorded as `--meta` entries so /// the rebuilt WASM has identical metadata to the original. /// +/// `--env=` bldopts are NOT forwarded as build flags: the original build +/// applied them via docker `-e` (recording them as `bldopt` only), so we replay +/// them the same way. The recorded value is shell-escaped, so we unescape it +/// back to a raw `NAME=VALUE` for docker `-e`. They're still re-recorded as +/// `bldopt` meta so the rebuilt WASM's metadata matches the original. +/// /// cliver is intentionally not re-injected — the container's stellar adds it /// automatically, and it will match the original's iff `bldimg` resolves to /// the same container. -fn build_container_command(meta: &ExtractedMetadata) -> Vec { - let mut forwarded: Vec = meta.bldopts.clone(); - let mut metadata: Vec = Vec::new(); - - let mut push_meta = |k: &str, v: &str| { - metadata.push("--meta".to_string()); - metadata.push(format!("{k}={v}")); - }; - push_meta("bldimg", &meta.bldimg); - if let Some(v) = &meta.source_uri { - push_meta("source_uri", v); - } - if let Some(v) = &meta.source_sha256 { - push_meta("source_sha256", v); - } +fn build_container_command(meta: &ExtractedMetadata) -> (Vec, Vec) { + let mut forwarded: Vec = Vec::new(); + let mut env: Vec = Vec::new(); for o in &meta.bldopts { - push_meta("bldopt", o); + if let Some(rest) = o.strip_prefix("--env=") { + // The bldopt value is shell-escaped (e.g. `--env=B='a b'`); shell-split + // it back to a single raw `NAME=VALUE` token for docker `-e`. + if let Some(kv) = + shlex::split(rest).and_then(|mut v| (v.len() == 1).then(|| v.remove(0))) + { + env.push(kv); + } + } else { + forwarded.push(o.clone()); + } } + // Re-record bldimg / source-ids / every bldopt as `--meta`, reusing the + // exact composition `build --verifiable` used, so the rebuilt WASM's + // metadata matches the original byte-for-byte. + let ids = verifiable::SourceIds { + source_uri: meta.source_uri.clone(), + source_sha256: meta.source_sha256.clone(), + }; + let metadata = verifiable::build_metadata_args(&meta.bldimg, &ids, &meta.bldopts); + // `--locked` is always sent — even if the original somehow lacked it (a // non-conformant build), the verifier insists on a locked rebuild so // dependency drift can't move bytes underneath us. @@ -610,7 +627,10 @@ fn build_container_command(meta: &ExtractedMetadata) -> Vec { forwarded.insert(0, "--locked".to_string()); } - verifiable::compose_container_args(&forwarded, &metadata) + ( + verifiable::compose_container_args(&forwarded, &metadata), + env, + ) } /// Locate the rebuilt WASM under `workdir`. The container writes to @@ -747,10 +767,7 @@ mod tests { meta.source_uri.as_deref(), Some("https://example.com/src.tar.gz") ); - assert_eq!( - meta.source_sha256.as_deref(), - Some("f".repeat(64).as_str()) - ); + assert_eq!(meta.source_sha256.as_deref(), Some("f".repeat(64).as_str())); } #[test] @@ -912,41 +929,6 @@ mod tests { assert!(matches!(err, Error::SourceHashMismatch { .. })); } - /// Build a tiny in-memory tar.gz with a single file and confirm extraction - /// drops the file at the expected path. Exercises the pure-Rust pipeline - /// (no shelling out, so this passes on Windows too). - #[test] - fn extract_tarball_unpacks_into_target() { - use flate2::write::GzEncoder; - use flate2::Compression; - use std::io::Write; - - let mut tar_bytes = Vec::new(); - { - let mut builder = tar::Builder::new(&mut tar_bytes); - let payload = b"contents"; - let mut header = tar::Header::new_gnu(); - header.set_path("hello.txt").unwrap(); - header.set_size(payload.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - builder.append(&header, &payload[..]).unwrap(); - builder.finish().unwrap(); - } - - let mut gz = Vec::new(); - { - let mut enc = GzEncoder::new(&mut gz, Compression::default()); - enc.write_all(&tar_bytes).unwrap(); - enc.finish().unwrap(); - } - - let dir = tempfile::TempDir::new().unwrap(); - extract_tarball(&gz, dir.path()).unwrap(); - let extracted = std::fs::read(dir.path().join("hello.txt")).unwrap(); - assert_eq!(extracted, b"contents"); - } - #[tokio::test] async fn materialize_source_errors_when_only_source_sha256() { let meta = ExtractedMetadata { @@ -955,11 +937,8 @@ mod tests { source_sha256: Some("f".repeat(64)), bldopts: Vec::new(), }; - let dir = tempfile::TempDir::new().unwrap(); let print = Print::new(true); - let err = materialize_source(&meta, None, dir.path(), &print) - .await - .unwrap_err(); + let err = materialize_source(&meta, None, &print).await.unwrap_err(); assert!(matches!(err, Error::SourceUriRequired)); } @@ -973,9 +952,11 @@ mod tests { "--locked".to_string(), "--meta=home_domain=fnando.com".to_string(), "--optimize".to_string(), + "--env=A=1".to_string(), + "--env=B='this is very nice'".to_string(), ], }; - let cmd = build_container_command(&meta); + let (cmd, env) = build_container_command(&meta); // Subcommand prefix. assert_eq!(&cmd[..2], &["contract".to_string(), "build".to_string()]); @@ -985,6 +966,14 @@ mod tests { assert!(cmd.contains(&"--meta=home_domain=fnando.com".to_string())); assert!(cmd.contains(&"--optimize".to_string())); + // `--env=` bldopts are applied via docker `-e` (unescaped), never + // forwarded as build flags. + assert!(!cmd.iter().any(|a| a.starts_with("--env="))); + assert_eq!( + env, + vec!["A=1".to_string(), "B=this is very nice".to_string()] + ); + // bldimg and source-ids are re-recorded as `--meta`. assert!(cmd .windows(2) @@ -993,11 +982,14 @@ mod tests { .windows(2) .any(|w| w[0] == "--meta" && w[1] == "source_uri=https://github.com/foo/bar")); - // Every bldopt is also re-recorded as a `bldopt=` meta so the rebuilt - // WASM mirrors the original's entries. + // Every bldopt — including the `--env=` ones — is re-recorded as a + // `bldopt=` meta so the rebuilt WASM mirrors the original's entries. assert!(cmd .windows(2) .any(|w| w[0] == "--meta" && w[1] == "bldopt=--locked")); + assert!(cmd + .windows(2) + .any(|w| w[0] == "--meta" && w[1] == "bldopt=--env=A=1")); } #[test] @@ -1010,7 +1002,7 @@ mod tests { source_sha256: Some("b".repeat(64)), bldopts: vec!["--meta=author=alice".to_string()], }; - let cmd = build_container_command(&meta); + let (cmd, _env) = build_container_command(&meta); let locked_count = cmd.iter().filter(|s| *s == "--locked").count(); assert_eq!( locked_count, 1, From fca405e95949eed4e61055b85514e29d4c5a460a Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 17 Jun 2026 15:37:33 -0700 Subject: [PATCH 19/20] Add --wasm-hash to stellar contract verify. --- FULL_HELP_DOCS.md | 13 ++--- .../src/commands/contract/verify.rs | 47 +++++++++++++------ 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index e87a48c44..3c0af3d48 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -1166,7 +1166,8 @@ Verify that a contract's WASM reproduces from the build metadata it records, per - `--id ` — Contract id or alias to fetch the WASM from the network - `--wasm ` — Local WASM file to verify, instead of fetching from the network -- `--tarball-url ` — Local tarball file or http(s) URL to use as the source when the WASM's recorded SEP-58 metadata has only `tarball_sha256` (no `tarball_url`). Accepts http(s) URLs or local file paths +- `--wasm-hash ` — WASM hash (hex) to fetch the WASM from the network +- `--source-uri ` — Local source code file or http(s) URL to use as the source when the WASM's recorded SEP-58 metadata has only `source_sha256` (no `source_uri`). Accepts http(s) URLs or local file paths - `--trust` — Bypass interactive confirmation when the WASM's bldimg is not in the default trust list, or when the source is a tarball (tarballs are never default-trusted) - `-d`, `--docker-host ` — Override the default docker host used by the rebuild @@ -4196,7 +4197,7 @@ Encode a transaction envelope from JSON to XDR Decode and encode XDR -**Usage:** `stellar xdr [CHANNEL] ` +**Usage:** `stellar xdr ` ###### **Subcommands:** @@ -4209,14 +4210,6 @@ Decode and encode XDR - `xfile` — Preprocess XDR .x files - `version` — Print version information -###### **Arguments:** - -- `` — Channel of XDR to operate on - - Default value: `+curr` - - Possible values: `+curr`, `+next` - ## `stellar xdr types` View information about types diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index 680a3fd4e..65065ee1f 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -5,7 +5,7 @@ use clap::Parser; use regex::Regex; use sha2::{Digest, Sha256}; use soroban_spec_tools::contract::Spec; -use stellar_xdr::curr::{ScMetaEntry, ScMetaV0}; +use stellar_xdr::{Hash, ScMetaEntry, ScMetaV0}; use crate::{ commands::{ @@ -25,13 +25,21 @@ use crate::{ #[group(skip)] pub struct Cmd { /// Contract id or alias to fetch the WASM from the network. - #[arg(long = "id", env = "STELLAR_CONTRACT_ID", conflicts_with = "wasm")] + #[arg( + long = "id", + env = "STELLAR_CONTRACT_ID", + conflicts_with_all = ["wasm", "wasm_hash"] + )] pub contract_id: Option, /// Local WASM file to verify, instead of fetching from the network. - #[arg(long)] + #[arg(long, conflicts_with = "wasm_hash")] pub wasm: Option, + /// WASM hash (hex) to fetch the WASM from the network. + #[arg(long = "wasm-hash")] + pub wasm_hash: Option, + /// Local source code file or http(s) URL to use as the source when the WASM's /// recorded SEP-58 metadata has only `source_sha256` (no `source_uri`). /// Accepts http(s) URLs or local file paths. @@ -57,9 +65,12 @@ pub struct Cmd { #[derive(thiserror::Error, Debug)] pub enum Error { - #[error("must pass exactly one of --id or --wasm")] + #[error("must pass exactly one of --id, --wasm, or --wasm-hash")] MissingInput, + #[error("invalid wasm hash {0:?}: expected 64 hex characters")] + InvalidWasmHash(String), + #[error("reading wasm {0}: {1}")] ReadWasm(PathBuf, std::io::Error), @@ -337,16 +348,24 @@ impl Cmd { } async fn fetch_wasm(&self) -> Result, Error> { - match (&self.contract_id, &self.wasm) { - (Some(id), None) => { - let network = self.network.get(&self.locator)?; - let resolved = - id.resolve_contract_id(&self.locator, &network.network_passphrase)?; - Ok(wasm::fetch_from_contract(&resolved, &network).await?) - } - (None, Some(path)) => std::fs::read(path).map_err(|e| Error::ReadWasm(path.clone(), e)), - _ => Err(Error::MissingInput), + // Clap keeps these three mutually exclusive, so at most one is set. + if let Some(path) = &self.wasm { + return std::fs::read(path).map_err(|e| Error::ReadWasm(path.clone(), e)); + } + if let Some(id) = &self.contract_id { + let network = self.network.get(&self.locator)?; + let resolved = id.resolve_contract_id(&self.locator, &network.network_passphrase)?; + return Ok(wasm::fetch_from_contract(&resolved, &network).await?); + } + if let Some(wasm_hash) = &self.wasm_hash { + let network = self.network.get(&self.locator)?; + let bytes: [u8; 32] = hex::decode(wasm_hash) + .ok() + .and_then(|b| b.try_into().ok()) + .ok_or_else(|| Error::InvalidWasmHash(wasm_hash.clone()))?; + return Ok(wasm::fetch_from_wasm_hash(Hash(bytes), &network).await?); } + Err(Error::MissingInput) } } @@ -722,7 +741,7 @@ const SOURCE_SHA256_REGEX_STR: &str = r"^[0-9a-f]{64}$"; mod tests { use super::*; use std::io::Cursor; - use stellar_xdr::curr::{Limited, Limits, ScMetaEntry, ScMetaV0, WriteXdr}; + use stellar_xdr::{Limited, Limits, ScMetaEntry, ScMetaV0, WriteXdr}; fn make_wasm_with_meta(entries: &[(&str, &str)]) -> Vec { let xdr = encode_meta(entries); From 93e1b1671f14830662766c005138b19095fe9307 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 18 Jun 2026 20:58:18 -0700 Subject: [PATCH 20/20] Stop requiring source hash when setting source URI. --- FULL_HELP_DOCS.md | 2 +- cmd/crates/soroban-test/tests/it/build.rs | 30 +++++++++++++++++-- .../src/commands/contract/build.rs | 11 +++---- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index 3c0af3d48..f713ee670 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -413,7 +413,7 @@ To view the commands that will be executed, without executing them, use the --pr - `--verifiable` — Build inside a trusted Docker container and record SEP-58 metadata (`bldimg`, `source_uri`, `source_sha256`, `bldopt`) so the resulting WASM can be reproduced and verified by third parties. Implies `--locked`. Requires a clean git working tree - `--image ` — Override the auto-selected container image used by `--verifiable`. Must be digest-pinned, e.g. `docker.io/stellar/stellar-cli@sha256:...`. Tag-only refs are rejected because SEP-58 requires content addressing - `--source-sha256 ` — SEP-58 source identification: SHA-256 of the source archive (recorded as the `source_sha256` meta entry). Optional with `--verifiable`: the archive is always generated and its SHA-256 computed for you. When supplied it's treated as a pin — the build fails if it doesn't match the generated archive -- `--source-uri ` — SEP-58 source identification: URI where the source can be obtained, e.g. `https://example.com/src.tar.gz` (recorded as the `source_uri` meta entry). Optional; when set it must accompany `--source-sha256` +- `--source-uri ` — SEP-58 source identification: URI where the source can be obtained, e.g. `https://example.com/src.tar.gz` (recorded as the `source_uri` meta entry). Optional with `--verifiable`; the recorded `source_sha256` is computed from the generated archive, unless `--source-sha256` is explicitly set - `-d`, `--docker-host ` — Override the default docker host used by `--verifiable` ## `stellar contract extend` diff --git a/cmd/crates/soroban-test/tests/it/build.rs b/cmd/crates/soroban-test/tests/it/build.rs index 8531c8ff7..19a49ed75 100644 --- a/cmd/crates/soroban-test/tests/it/build.rs +++ b/cmd/crates/soroban-test/tests/it/build.rs @@ -1289,8 +1289,6 @@ fn verifiable_source_uri_format_errors() { .arg("--verifiable") .arg("--image") .arg(ZERO_DIGEST) - .arg("--source-sha256") - .arg("a".repeat(64)) .arg("--source-uri") .arg("not a uri") .assert() @@ -1298,6 +1296,34 @@ fn verifiable_source_uri_format_errors() { .stderr(predicate::str::contains("source_uri format")); } +// `--source-uri` does not require `--source-sha256`: the archive is generated +// and its source_sha256 computed regardless, so a source-uri-only build gets +// past validation and writes the archive (then fails reaching a real image). +#[test] +fn verifiable_source_uri_without_sha256_is_allowed() { + let sandbox = TestEnv::default(); + let (_temp, workspace) = fresh_workspace(); + git_in(&workspace, &["init", "-q", "-b", "main"]); + git_in(&workspace, &["add", "-A"]); + git_in(&workspace, &["commit", "-q", "-m", "init"]); + + sandbox + .new_assert_cmd("contract") + .current_dir(workspace.join("contracts").join("add")) + .arg("build") + .arg("--verifiable") + .arg("--image") + .arg(ZERO_DIGEST) + .arg("--source-uri") + .arg("https://example.com/src.tar.gz") + .assert() + .failure() + .stderr( + predicate::str::contains("Wrote source archive") + .and(predicate::str::contains("source_sha256")), + ); +} + // A dirty git tree is a hard fail under `--verifiable` (the recorded // source_sha256 would not describe the bytes built). #[test] diff --git a/cmd/soroban-cli/src/commands/contract/build.rs b/cmd/soroban-cli/src/commands/contract/build.rs index 56178b474..a3ab0fb44 100644 --- a/cmd/soroban-cli/src/commands/contract/build.rs +++ b/cmd/soroban-cli/src/commands/contract/build.rs @@ -122,13 +122,10 @@ pub struct Cmd { /// SEP-58 source identification: URI where the source can be obtained, e.g. /// `https://example.com/src.tar.gz` (recorded as the `source_uri` meta - /// entry). Optional; when set it must accompany `--source-sha256`. - #[arg( - long, - requires = "verifiable", - requires = "source_sha256", - help_heading = "Verifiable" - )] + /// entry). Optional with `--verifiable`; the recorded `source_sha256` is + /// computed from the generated archive, unless `--source-sha256` is + /// explicitly set. + #[arg(long, requires = "verifiable", help_heading = "Verifiable")] pub source_uri: Option, /// Override the default docker host used by `--verifiable`.