From e0963f2304196ac1fb9e7bbc279d35b1ad903120 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 14:41:36 -0700 Subject: [PATCH 01/58] Add --verifiable flag to stellar contract build. --- FULL_HELP_DOCS.md | 6 + cmd/crates/soroban-test/tests/it/build.rs | 82 +++ .../src/commands/contract/build.rs | 33 +- .../src/commands/contract/build/verifiable.rs | 629 ++++++++++++++++++ .../src/commands/contract/deploy/wasm.rs | 6 +- cmd/soroban-cli/src/commands/contract/mod.rs | 2 +- .../src/commands/contract/upload.rs | 6 +- 7 files changed, 755 insertions(+), 9 deletions(-) create mode 100644 cmd/soroban-cli/src/commands/contract/build/verifiable.rs diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index fbdd59db03..f0ca271564 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -384,6 +384,7 @@ To view the commands that will be executed, without executing them, use the --pr If ommitted, wasm files are written only to the cargo target directory. - `--locked` — Assert that `Cargo.lock` will remain unchanged +- `-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 - `--optimize ` — Optimize the generated wasm. Enabled by default; pass `--optimize=false` to disable. Requires the `additional-libs` feature Default value: `true` @@ -394,6 +395,11 @@ To view the commands that will be executed, without executing them, use the --pr - `--print-commands-only` — Print commands to build without executing them +###### **Verifiable:** + +- `--verifiable` — Build inside a trusted Docker container and record SEP-58 metadata (`bldimg`, `source_rev`, `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 + ## `stellar contract extend` Extend the time to live ledger of a contract-data ledger entry. diff --git a/cmd/crates/soroban-test/tests/it/build.rs b/cmd/crates/soroban-test/tests/it/build.rs index b5f7631ca6..5936bfeacf 100644 --- a/cmd/crates/soroban-test/tests/it/build.rs +++ b/cmd/crates/soroban-test/tests/it/build.rs @@ -993,3 +993,85 @@ fn build_always_injects_cli_version() { "CLI version should not be empty" ); } + +// `--verifiable` cannot accept reserved `--meta` keys that the cli writes itself. +#[test] +fn verifiable_meta_conflict_errors() { + let sandbox = TestEnv::default(); + let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/workspace/contracts/add"); + + sandbox + .new_assert_cmd("contract") + .current_dir(fixture_path) + .arg("build") + .arg("--verifiable") + .arg("--image") + .arg("docker.io/stellar/stellar-cli@sha256:0000000000000000000000000000000000000000000000000000000000000000") + .arg("--meta") + .arg("bldimg=not-allowed") + .assert() + .failure() + .stderr(predicate::str::contains("reserved key: bldimg")); +} + +// `--image` must be content-addressed; tag-only refs are rejected. +#[test] +fn verifiable_image_must_be_digest_pinned() { + let sandbox = TestEnv::default(); + let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/workspace/contracts/add"); + + sandbox + .new_assert_cmd("contract") + .current_dir(fixture_path) + .arg("build") + .arg("--verifiable") + .arg("--image") + .arg("docker.io/stellar/stellar-cli:latest") + .assert() + .failure() + .stderr(predicate::str::contains("must be digest-pinned")); +} + +// A dirty git tree breaks the verifiability property because `source_rev` would +// record a commit whose bytes don't match the produced WASM. Hard fail. +#[test] +fn verifiable_dirty_tree_errors() { + let sandbox = TestEnv::default(); + let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/workspace"); + let temp = TempDir::new().unwrap(); + let dir_path = temp.path(); + fs_extra::dir::copy(fixture_path, dir_path, &CopyOptions::new()).unwrap(); + let workspace = dir_path.join("workspace"); + + // Bootstrap a clean git tree at the workspace root, then dirty it so the + // verifiable path's dirty-check trips before docker is touched. + let git = |args: &[&str]| { + std::process::Command::new("git") + .args(args) + .current_dir(&workspace) + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .status() + .unwrap(); + }; + git(&["init", "-q", "-b", "main"]); + git(&["add", "-A"]); + git(&["commit", "-q", "-m", "init"]); + std::fs::write(workspace.join("dirty.txt"), b"uncommitted").unwrap(); + + sandbox + .new_assert_cmd("contract") + .current_dir(workspace.join("contracts").join("add")) + .arg("build") + .arg("--verifiable") + .arg("--image") + .arg("docker.io/stellar/stellar-cli@sha256:0000000000000000000000000000000000000000000000000000000000000000") + .assert() + .failure() + .stderr(predicate::str::contains("dirty").or(predicate::str::contains("clean tree"))); +} diff --git a/cmd/soroban-cli/src/commands/contract/build.rs b/cmd/soroban-cli/src/commands/contract/build.rs index ed3ce7e1bd..c6f1173f74 100644 --- a/cmd/soroban-cli/src/commands/contract/build.rs +++ b/cmd/soroban-cli/src/commands/contract/build.rs @@ -20,11 +20,13 @@ use stellar_xdr::{Limited, Limits, ScMetaEntry, ScMetaV0, StringM, WriteXdr}; #[cfg(feature = "additional-libs")] use crate::commands::contract::optimize; use crate::{ - commands::{global, version}, + commands::{container, global, version}, print::Print, wasm, }; +pub mod verifiable; + /// A built WASM artifact with its package name and file path. #[derive(Debug, Clone)] pub struct BuiltContract { @@ -96,6 +98,22 @@ pub struct Cmd { #[arg(long, conflicts_with = "out_dir", help_heading = "Other")] pub print_commands_only: bool, + /// Build inside a trusted Docker container and record SEP-58 metadata + /// (`bldimg`, `source_rev`, `bldopt`) so the resulting WASM can be + /// reproduced and verified by third parties. Implies `--locked`. + /// Requires a clean git working tree. + #[arg(long, help_heading = "Verifiable")] + pub verifiable: bool, + + /// 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. + #[arg(long, requires = "verifiable", help_heading = "Verifiable")] + pub image: Option, + + #[command(flatten)] + pub container_args: container::shared::Args, + #[command(flatten)] pub build_args: BuildArgs, } @@ -204,6 +222,9 @@ pub enum Error { #[error("wasm parsing error: {0}")] WasmParsing(String), + + #[error(transparent)] + Verifiable(#[from] verifiable::Error), } const WASM_TARGET: &str = "wasm32v1-none"; @@ -222,6 +243,9 @@ impl Default for Cmd { out_dir: None, locked: false, print_commands_only: false, + verifiable: false, + image: None, + container_args: container::shared::Args { docker_host: None }, build_args: BuildArgs::default(), } } @@ -230,8 +254,13 @@ impl Default for Cmd { impl Cmd { /// Builds the project and returns the built WASM artifacts. #[allow(clippy::too_many_lines)] - pub fn run(&self, global_args: &global::Args) -> Result, Error> { + pub async fn run(&self, global_args: &global::Args) -> Result, Error> { let print = Print::new(global_args.quiet); + + if self.verifiable { + return verifiable::run(self, global_args, &print).await; + } + let working_dir = env::current_dir().map_err(Error::GettingCurrentDir)?; let metadata = self.metadata()?; let packages = self.packages(&metadata)?; diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs new file mode 100644 index 0000000000..80fa1c11b7 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -0,0 +1,629 @@ +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +use bollard::{ + models::ContainerCreateBody, + query_parameters::{ + AttachContainerOptions, CreateContainerOptions, CreateImageOptions, StartContainerOptions, + WaitContainerOptions, + }, + service::HostConfig, + Docker, +}; +use cargo_metadata::MetadataCommand; +use futures_util::{StreamExt, TryStreamExt}; +use regex::Regex; +use semver::Version; +use serde::Deserialize; + +use crate::{ + commands::{container::shared::Error as ConnectionError, global}, + print::Print, +}; + +use super::{BuiltContract, Cmd, WASM_TARGET}; + +const REGISTRY: &str = "docker.io/stellar/stellar-cli"; +const HUB_TAGS_URL: &str = + "https://hub.docker.com/v2/repositories/stellar/stellar-cli/tags/?page_size=100"; +const RESERVED_META_KEYS: &[&str] = &["bldimg", "source_rev", "bldopt"]; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("⛔ failed to connect to docker: {0}")] + DockerConnection(#[from] ConnectionError), + + #[error(transparent)] + Bollard(#[from] bollard::errors::Error), + + #[error("--image must be digest-pinned (got {value}); SEP-58 requires content-addressed images. Pass docker.io/stellar/stellar-cli@sha256:")] + ImageNotDigestPinned { value: String }, + + #[error("could not determine the running rustc version: {0}")] + RustcVersion(String), + + #[error("could not pull image {tag}: {source}\n\nAvailable tags for this CLI version: {available_for_cli}\nAll published cli/rust pairs: {all_grouped}\n\nFix: install a matching rustc, or pass --image docker.io/stellar/stellar-cli@sha256: with one of the listed tags resolved to a digest.")] + ImageNotFound { + tag: String, + available_for_cli: String, + all_grouped: String, + source: bollard::errors::Error, + }, + + #[error("could not list published images on docker hub: {0}")] + TagListUnavailable(String), + + #[error("image {tag} has no repo digest after pull; cannot record a content-addressed bldimg")] + NoRepoDigest { tag: String }, + + #[error("cargo metadata failed: {0}")] + Metadata(#[from] cargo_metadata::Error), + + #[error("could not read git state at {path}: {source}")] + GitInvoke { + path: PathBuf, + source: std::io::Error, + }, + + #[error( + "git working tree at {path} is dirty. Verifiable builds require a clean tree so the recorded source_rev matches the WASM bytes. Commit or stash your changes and try again." + )] + GitDirty { path: PathBuf }, + + #[error( + "the cli sets bldimg, source_rev, and bldopt automatically when --verifiable is used; remove them from --meta. Got reserved key: {key}" + )] + ReservedMetaKey { key: String }, + + #[error("container build exited with status {status}. To reproduce manually:\n docker run --rm -v {mount}:/source {image} contract build {args}")] + ContainerExit { + status: i64, + image: String, + mount: String, + args: String, + }, +} + +pub async fn run( + cmd: &Cmd, + global_args: &global::Args, + print: &Print, +) -> Result, super::Error> { + // Stage 1: pure validation, no I/O. + for (k, _) in &cmd.build_args.meta { + if RESERVED_META_KEYS.iter().any(|r| r == k) { + return Err(Error::ReservedMetaKey { key: k.clone() }.into()); + } + } + if let Some(img) = &cmd.image { + if !img.contains("@sha256:") { + return Err(Error::ImageNotDigestPinned { value: img.clone() }.into()); + } + } + + if !cmd.locked { + print.infoln("--verifiable implies --locked"); + } + + // Stage 2: local filesystem + git, no network. + let workspace_root = resolve_workspace_root(cmd)?; + let source_rev = git_source_rev(&workspace_root, print)?; + + // Stage 3: docker. + let docker = cmd + .container_args + .connect_to_docker(print) + .await + .map_err(Error::DockerConnection)?; + let image_ref = resolve_image(cmd, &docker, print).await?; + + let (forwarded_args, bldopts) = build_forwarded_args(cmd); + let metadata_args = build_metadata_args(&image_ref, &source_rev, &bldopts); + let container_cmd_args = compose_container_args(&forwarded_args, &metadata_args); + + run_in_container( + &image_ref, + &workspace_root, + &container_cmd_args, + &docker, + print, + ) + .await?; + + let _ = global_args; + collect_built_contracts(cmd, &workspace_root, print) +} + +fn resolve_workspace_root(cmd: &Cmd) -> Result { + let mut mc = MetadataCommand::new(); + mc.no_deps(); + if let Some(p) = &cmd.manifest_path { + mc.manifest_path(p); + } + let md = mc.exec()?; + Ok(md.workspace_root.into_std_path_buf()) +} + +fn git_source_rev(workspace_root: &Path, print: &Print) -> Result { + // Probe with rev-parse first to detect "not a git repo". + let rev = Command::new("git") + .arg("-C") + .arg(workspace_root) + .arg("rev-parse") + .arg("HEAD") + .output(); + let rev = match rev { + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(), + Ok(_) => { + print.warnln(format!( + "{} is not a git repository; recording empty source_rev (verifiability is degraded).", + workspace_root.display() + )); + return Ok(String::new()); + } + Err(e) => { + return Err(Error::GitInvoke { + path: workspace_root.to_path_buf(), + source: e, + }) + } + }; + + // Dirty check. + let status = Command::new("git") + .arg("-C") + .arg(workspace_root) + .arg("status") + .arg("--porcelain") + .output() + .map_err(|e| Error::GitInvoke { + path: workspace_root.to_path_buf(), + source: e, + })?; + if !status.stdout.is_empty() { + return Err(Error::GitDirty { + path: workspace_root.to_path_buf(), + }); + } + + Ok(rev) +} + +/// The flags forwarded to the container's `stellar contract build`, plus the +/// bldopt strings recorded into SEP-58 metadata. `--locked` is always present. +fn build_forwarded_args(cmd: &Cmd) -> (Vec, Vec) { + let mut forwarded: Vec = Vec::new(); + let mut bldopts: Vec = Vec::new(); + + forwarded.push("--locked".to_string()); + bldopts.push("--locked".to_string()); + + if cmd.profile != "release" { + let s = format!("--profile={}", cmd.profile); + forwarded.push(s.clone()); + bldopts.push(s); + } + if let Some(features) = &cmd.features { + let s = format!("--features={features}"); + forwarded.push(s.clone()); + bldopts.push(s); + } + if cmd.all_features { + forwarded.push("--all-features".to_string()); + bldopts.push("--all-features".to_string()); + } + if cmd.no_default_features { + forwarded.push("--no-default-features".to_string()); + bldopts.push("--no-default-features".to_string()); + } + if let Some(pkg) = &cmd.package { + let s = format!("--package={pkg}"); + forwarded.push(s.clone()); + bldopts.push(s); + } + + // User-supplied --meta entries (none of which can collide with reserved keys + // because we already errored on that). + for (k, v) in &cmd.build_args.meta { + forwarded.push("--meta".to_string()); + forwarded.push(format!("{k}={v}")); + } + + if !cmd.build_args.optimize { + forwarded.push("--optimize=false".to_string()); + } + + (forwarded, bldopts) +} + +fn build_metadata_args(image_ref: &str, source_rev: &str, bldopts: &[String]) -> Vec { + let mut out = Vec::new(); + for (k, v) in [("bldimg", image_ref), ("source_rev", source_rev)] { + out.push("--meta".to_string()); + out.push(format!("{k}={v}")); + } + for o in bldopts { + out.push("--meta".to_string()); + out.push(format!("bldopt={o}")); + } + out +} + +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); + args +} + +pub async fn resolve_image(cmd: &Cmd, docker: &Docker, print: &Print) -> Result { + if let Some(s) = &cmd.image { + if !s.contains("@sha256:") { + return Err(Error::ImageNotDigestPinned { value: s.clone() }); + } + return Ok(s.clone()); + } + + let cli_v = env!("CARGO_PKG_VERSION"); + let rust_v = rustc_version::version() + .map_err(|e| Error::RustcVersion(e.to_string()))? + .to_string(); + let tag = format!("{REGISTRY}:{cli_v}-rust{rust_v}"); + + print.infoln(format!("Pulling verifiable build image {tag}")); + let pull = pull_image(docker, &tag, print).await; + + match pull { + Ok(()) => {} + Err(e) => { + let (available_for_cli, all_grouped) = match list_published_tags().await { + Ok(tags) => format_available(&tags, cli_v), + Err(list_err) => ( + "".to_string(), + format!(""), + ), + }; + return Err(Error::ImageNotFound { + tag, + available_for_cli, + all_grouped, + source: e, + }); + } + } + + let inspect = docker.inspect_image(&tag).await?; + let digest = inspect + .repo_digests + .and_then(|v| v.into_iter().next()) + .ok_or_else(|| Error::NoRepoDigest { tag: tag.clone() })?; + Ok(digest) +} + +async fn pull_image( + docker: &Docker, + tag: &str, + print: &Print, +) -> Result<(), bollard::errors::Error> { + let mut stream = docker.create_image( + Some(CreateImageOptions { + from_image: Some(tag.to_string()), + ..Default::default() + }), + None, + None, + ); + while let Some(item) = stream.try_next().await? { + if let Some(status) = item.status { + if status.contains("Pulling from") + || status.contains("Digest") + || status.contains("Status") + { + print.infoln(status); + } + } + } + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct PublishedTag { + pub cli: Version, + pub rust: Version, + pub raw: String, +} + +#[derive(Deserialize)] +struct HubPage { + results: Vec, + next: Option, +} + +#[derive(Deserialize)] +struct HubTag { + name: String, +} + +pub async fn list_published_tags() -> Result, Error> { + let re = Regex::new(r"^(\d+\.\d+\.\d+)-rust(\d+\.\d+\.\d+)$").unwrap(); + let mut out = Vec::new(); + let mut next = Some(HUB_TAGS_URL.to_string()); + let client = reqwest::Client::builder() + .user_agent("stellar-cli") + .build() + .map_err(|e| Error::TagListUnavailable(e.to_string()))?; + while let Some(url) = next { + let page: HubPage = client + .get(&url) + .send() + .await + .map_err(|e| Error::TagListUnavailable(e.to_string()))? + .error_for_status() + .map_err(|e| Error::TagListUnavailable(e.to_string()))? + .json() + .await + .map_err(|e| Error::TagListUnavailable(e.to_string()))?; + for t in page.results { + if let Some(c) = re.captures(&t.name) { + let cli = Version::parse(&c[1]); + let rust = Version::parse(&c[2]); + if let (Ok(cli), Ok(rust)) = (cli, rust) { + out.push(PublishedTag { + cli, + rust, + raw: t.name, + }); + } + } + } + next = page.next; + } + Ok(out) +} + +fn format_available(tags: &[PublishedTag], current_cli: &str) -> (String, String) { + let current = Version::parse(current_cli).ok(); + let mut for_this_cli: Vec<&PublishedTag> = tags + .iter() + .filter(|t| Some(&t.cli) == current.as_ref()) + .collect(); + for_this_cli.sort_by(|a, b| b.rust.cmp(&a.rust)); + let available_for_cli = if for_this_cli.is_empty() { + "".to_string() + } else { + for_this_cli + .iter() + .map(|t| t.raw.as_str()) + .collect::>() + .join(", ") + }; + + let mut by_cli: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for t in tags { + by_cli + .entry(t.cli.to_string()) + .or_default() + .push(t.rust.to_string()); + } + let all_grouped = by_cli + .into_iter() + .map(|(cli, rusts)| format!("{cli}: [{}]", rusts.join(", "))) + .collect::>() + .join("; "); + + (available_for_cli, all_grouped) +} + +async fn run_in_container( + image_ref: &str, + workspace_root: &Path, + container_cmd: &[String], + docker: &Docker, + print: &Print, +) -> Result<(), Error> { + let bind = format!("{}:/source", workspace_root.display()); + let config = ContainerCreateBody { + image: Some(image_ref.to_string()), + cmd: Some(container_cmd.to_vec()), + working_dir: Some("/source".to_string()), + attach_stdout: Some(true), + attach_stderr: Some(true), + host_config: Some(HostConfig { + auto_remove: Some(true), + binds: Some(vec![bind.clone()]), + ..Default::default() + }), + ..Default::default() + }; + + print.infoln(format!( + "Running verifiable build in {image_ref} (mount {bind})" + )); + + let created = docker + .create_container(None::, config) + .await?; + + let attached = docker + .attach_container( + &created.id, + Some(AttachContainerOptions { + stdout: true, + stderr: true, + stream: true, + ..Default::default() + }), + ) + .await?; + + docker + .start_container(&created.id, None::) + .await?; + + let mut output = attached.output; + while let Some(chunk) = output.next().await { + match chunk { + Ok( + bollard::container::LogOutput::StdOut { message } + | bollard::container::LogOutput::StdErr { message }, + ) => { + let s = String::from_utf8_lossy(&message); + print.blankln(s.trim_end()); + } + Ok(_) => {} + Err(e) => return Err(e.into()), + } + } + + let mut wait = docker.wait_container(&created.id, None::); + while let Some(item) = wait.next().await { + match item { + Ok(r) if r.status_code == 0 => {} + Ok(r) => { + return Err(Error::ContainerExit { + status: r.status_code, + image: image_ref.to_string(), + mount: workspace_root.display().to_string(), + args: container_cmd.join(" "), + }); + } + Err(bollard::errors::Error::DockerContainerWaitError { code: 0, .. }) => {} + Err(bollard::errors::Error::DockerContainerWaitError { code, .. }) => { + return Err(Error::ContainerExit { + status: code, + image: image_ref.to_string(), + mount: workspace_root.display().to_string(), + args: container_cmd.join(" "), + }); + } + Err(e) => return Err(e.into()), + } + } + + Ok(()) +} + +fn collect_built_contracts( + cmd: &Cmd, + workspace_root: &Path, + _print: &Print, +) -> Result, super::Error> { + let mut mc = MetadataCommand::new(); + mc.no_deps(); + if let Some(p) = &cmd.manifest_path { + mc.manifest_path(p); + } + let md = mc.exec().map_err(Error::Metadata)?; + let target_dir = md.target_directory.as_std_path(); + + let mut out = Vec::new(); + for p in &md.packages { + let is_cdylib = p + .targets + .iter() + .any(|t| t.crate_types.iter().any(|c| c == "cdylib")); + if !is_cdylib { + continue; + } + if let Some(name) = &cmd.package { + if &p.name != name { + continue; + } + } else if !md.workspace_default_members.contains(&p.id) { + continue; + } + let wasm_name = p.name.replace('-', "_"); + let path = Path::new(target_dir) + .join(WASM_TARGET) + .join(&cmd.profile) + .join(format!("{wasm_name}.wasm")); + if let Some(out_dir) = &cmd.out_dir { + let dest = out_dir.join(format!("{wasm_name}.wasm")); + if path.exists() { + std::fs::create_dir_all(out_dir).map_err(super::Error::CreatingOutDir)?; + std::fs::copy(&path, &dest).map_err(super::Error::CopyingWasmFile)?; + out.push(BuiltContract { + name: p.name.clone(), + path: dest, + }); + continue; + } + } + out.push(BuiltContract { + name: p.name.clone(), + path, + }); + } + let _ = workspace_root; + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_forwarded_args_defaults() { + let cmd = Cmd::default(); + let (forwarded, bldopts) = build_forwarded_args(&cmd); + assert_eq!(forwarded, vec!["--locked".to_string()]); + assert_eq!(bldopts, vec!["--locked".to_string()]); + } + + #[test] + fn build_forwarded_args_features_and_package() { + let cmd = Cmd { + features: Some("a,b".to_string()), + package: Some("contract-a".to_string()), + ..Cmd::default() + }; + let (forwarded, bldopts) = build_forwarded_args(&cmd); + assert!(forwarded.contains(&"--features=a,b".to_string())); + assert!(forwarded.contains(&"--package=contract-a".to_string())); + assert!(bldopts.contains(&"--features=a,b".to_string())); + assert!(bldopts.contains(&"--package=contract-a".to_string())); + assert!(bldopts.contains(&"--locked".to_string())); + } + + #[test] + fn build_metadata_args_orders_keys() { + let m = build_metadata_args( + "docker.io/stellar/stellar-cli@sha256:abc", + "deadbeef", + &["--locked".to_string(), "--features=a".to_string()], + ); + // bldimg, source_rev, then bldopts in order. + let pairs: Vec<(&str, &str)> = m + .chunks(2) + .map(|c| (c[0].as_str(), c[1].as_str())) + .collect(); + assert_eq!( + pairs[0], + ("--meta", "bldimg=docker.io/stellar/stellar-cli@sha256:abc") + ); + assert_eq!(pairs[1], ("--meta", "source_rev=deadbeef")); + assert_eq!(pairs[2], ("--meta", "bldopt=--locked")); + assert_eq!(pairs[3], ("--meta", "bldopt=--features=a")); + } + + #[test] + fn compose_container_args_prefixes_subcommand() { + let composed = compose_container_args( + &["--locked".to_string()], + &["--meta".to_string(), "bldimg=x".to_string()], + ); + assert_eq!(composed[..2], ["contract".to_string(), "build".to_string()]); + assert!(composed.contains(&"--locked".to_string())); + assert!(composed.contains(&"bldimg=x".to_string())); + } + + #[test] + fn reserved_meta_keys_list() { + for key in ["bldimg", "source_rev", "bldopt"] { + assert!(RESERVED_META_KEYS.contains(&key)); + } + } +} diff --git a/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs index 9afdada059..5ba2d37aa7 100644 --- a/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs +++ b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs @@ -193,7 +193,7 @@ impl Cmd { return Err(Error::BuildOnlyNotSupported); } - let built_contracts = self.resolve_contracts(global_args)?; + let built_contracts = self.resolve_contracts(global_args).await?; // Aliases derived from workspace package names are assigned per-iteration // inside the deploy loop, so validate them all up front: a package named @@ -286,7 +286,7 @@ impl Cmd { Ok(()) } - fn resolve_contracts( + async fn resolve_contracts( &self, global_args: &global::Args, ) -> Result, Error> { @@ -309,7 +309,7 @@ impl Cmd { build_args: self.build_args.clone(), ..build::Cmd::default() }; - let contracts = build_cmd.run(global_args).map_err(|e| match e { + let contracts = build_cmd.run(global_args).await.map_err(|e| match e { build::Error::Metadata(_) => Error::NotInCargoProject, other => other.into(), })?; diff --git a/cmd/soroban-cli/src/commands/contract/mod.rs b/cmd/soroban-cli/src/commands/contract/mod.rs index a5e6ce181c..fc4499c029 100644 --- a/cmd/soroban-cli/src/commands/contract/mod.rs +++ b/cmd/soroban-cli/src/commands/contract/mod.rs @@ -164,7 +164,7 @@ impl Cmd { Cmd::Asset(asset) => asset.run(global_args).await?, Cmd::Bindings(bindings) => bindings.run().await?, Cmd::Build(build) => { - build.run(global_args)?; + build.run(global_args).await?; } Cmd::Extend(extend) => extend.run(global_args).await?, Cmd::Alias(alias) => alias.run(global_args)?, diff --git a/cmd/soroban-cli/src/commands/contract/upload.rs b/cmd/soroban-cli/src/commands/contract/upload.rs index 55b8e308b9..9a843880d4 100644 --- a/cmd/soroban-cli/src/commands/contract/upload.rs +++ b/cmd/soroban-cli/src/commands/contract/upload.rs @@ -144,7 +144,7 @@ impl Cmd { return Err(Error::BuildOnlyNotSupported); } - let wasm_paths = self.resolve_wasm_paths(global_args)?; + let wasm_paths = self.resolve_wasm_paths(global_args).await?; for wasm_path in &wasm_paths { let res = self @@ -181,7 +181,7 @@ impl Cmd { self.upload_wasm(&wasm_path, config, quiet, no_cache).await } - fn resolve_wasm_paths(&self, global_args: &global::Args) -> Result, Error> { + async fn resolve_wasm_paths(&self, global_args: &global::Args) -> Result, Error> { if let Some(wasm) = &self.wasm { Ok(vec![wasm.clone()]) } else { @@ -190,7 +190,7 @@ impl Cmd { build_args: self.build_args.clone(), ..build::Cmd::default() }; - let contracts = build_cmd.run(global_args).map_err(|e| match e { + let contracts = build_cmd.run(global_args).await.map_err(|e| match e { build::Error::Metadata(_) => Error::NotInCargoProject, other => other.into(), })?; From b04676dc66382e37461afba8b2d50f372558f854 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 15:07:25 -0700 Subject: [PATCH 02/58] Record every build-affecting flag as bldopt. --- .../src/commands/contract/build/verifiable.rs | 88 +++++++++++++------ 1 file changed, 61 insertions(+), 27 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 80fa1c11b7..7d61322c40 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -119,7 +119,7 @@ pub async fn run( .map_err(Error::DockerConnection)?; let image_ref = resolve_image(cmd, &docker, print).await?; - let (forwarded_args, bldopts) = build_forwarded_args(cmd); + let (forwarded_args, bldopts) = build_forwarded_args(cmd, &workspace_root); let metadata_args = build_metadata_args(&image_ref, &source_rev, &bldopts); let container_cmd_args = compose_container_args(&forwarded_args, &metadata_args); @@ -192,47 +192,51 @@ fn git_source_rev(workspace_root: &Path, print: &Print) -> Result } /// The flags forwarded to the container's `stellar contract build`, plus the -/// bldopt strings recorded into SEP-58 metadata. `--locked` is always present. -fn build_forwarded_args(cmd: &Cmd) -> (Vec, Vec) { +/// bldopt strings recorded into SEP-58 metadata. Every build-affecting flag +/// becomes one bldopt entry so a verifier can replay the same invocation. +/// `--locked` is always present. `manifest_path` (when set) is recorded +/// relative to the workspace root so it's valid inside `/source`. +fn build_forwarded_args(cmd: &Cmd, workspace_root: &Path) -> (Vec, Vec) { let mut forwarded: Vec = Vec::new(); let mut bldopts: Vec = Vec::new(); - forwarded.push("--locked".to_string()); - bldopts.push("--locked".to_string()); + let mut record = |arg: String| { + forwarded.push(arg.clone()); + bldopts.push(arg); + }; + + record("--locked".to_string()); + if let Some(path) = &cmd.manifest_path { + let abs = std::path::absolute(path).unwrap_or_else(|_| path.clone()); + let rel = abs + .strip_prefix(workspace_root) + .map(Path::to_path_buf) + .unwrap_or(abs); + record(format!("--manifest-path={}", rel.display())); + } if cmd.profile != "release" { - let s = format!("--profile={}", cmd.profile); - forwarded.push(s.clone()); - bldopts.push(s); + record(format!("--profile={}", cmd.profile)); } if let Some(features) = &cmd.features { - let s = format!("--features={features}"); - forwarded.push(s.clone()); - bldopts.push(s); + record(format!("--features={features}")); } if cmd.all_features { - forwarded.push("--all-features".to_string()); - bldopts.push("--all-features".to_string()); + record("--all-features".to_string()); } if cmd.no_default_features { - forwarded.push("--no-default-features".to_string()); - bldopts.push("--no-default-features".to_string()); + record("--no-default-features".to_string()); } if let Some(pkg) = &cmd.package { - let s = format!("--package={pkg}"); - forwarded.push(s.clone()); - bldopts.push(s); + record(format!("--package={pkg}")); } - - // User-supplied --meta entries (none of which can collide with reserved keys - // because we already errored on that). for (k, v) in &cmd.build_args.meta { - forwarded.push("--meta".to_string()); - forwarded.push(format!("{k}={v}")); + // Use the `--meta=key=value` form so each option is a single token, + // matching how clap re-parses on the container side. + record(format!("--meta={k}={v}")); } - if !cmd.build_args.optimize { - forwarded.push("--optimize=false".to_string()); + record("--optimize=false".to_string()); } (forwarded, bldopts) @@ -565,10 +569,14 @@ fn collect_built_contracts( mod tests { use super::*; + fn ws() -> &'static Path { + Path::new("/tmp/ws") + } + #[test] fn build_forwarded_args_defaults() { let cmd = Cmd::default(); - let (forwarded, bldopts) = build_forwarded_args(&cmd); + let (forwarded, bldopts) = build_forwarded_args(&cmd, ws()); assert_eq!(forwarded, vec!["--locked".to_string()]); assert_eq!(bldopts, vec!["--locked".to_string()]); } @@ -580,7 +588,7 @@ mod tests { package: Some("contract-a".to_string()), ..Cmd::default() }; - let (forwarded, bldopts) = build_forwarded_args(&cmd); + let (forwarded, bldopts) = build_forwarded_args(&cmd, ws()); assert!(forwarded.contains(&"--features=a,b".to_string())); assert!(forwarded.contains(&"--package=contract-a".to_string())); assert!(bldopts.contains(&"--features=a,b".to_string())); @@ -588,6 +596,32 @@ mod tests { assert!(bldopts.contains(&"--locked".to_string())); } + #[test] + fn build_forwarded_args_records_meta_optimize_and_manifest() { + let cmd = Cmd { + manifest_path: Some(PathBuf::from("/tmp/ws/contracts/add/Cargo.toml")), + build_args: super::super::BuildArgs { + meta: vec![ + ("home_domain".to_string(), "fnando.com".to_string()), + ("author".to_string(), "alice".to_string()), + ], + optimize: false, + }, + ..Cmd::default() + }; + let (forwarded, bldopts) = build_forwarded_args(&cmd, ws()); + assert!(forwarded.contains(&"--meta=home_domain=fnando.com".to_string())); + assert!(forwarded.contains(&"--meta=author=alice".to_string())); + assert!(forwarded.contains(&"--optimize=false".to_string())); + assert!(forwarded.contains(&"--manifest-path=contracts/add/Cargo.toml".to_string())); + // Same set is captured into bldopts so a verifier can replay every + // build-affecting flag. + assert!(bldopts.contains(&"--meta=home_domain=fnando.com".to_string())); + assert!(bldopts.contains(&"--meta=author=alice".to_string())); + assert!(bldopts.contains(&"--optimize=false".to_string())); + assert!(bldopts.contains(&"--manifest-path=contracts/add/Cargo.toml".to_string())); + } + #[test] fn build_metadata_args_orders_keys() { let m = build_metadata_args( From 1a118d14b4d9cfe4622d01b2b2adef3ebc234ab3 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 15:18:03 -0700 Subject: [PATCH 03/58] Remove duplicate 'contract build' in container error hint. --- cmd/soroban-cli/src/commands/contract/build/verifiable.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 7d61322c40..2c77226701 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -77,7 +77,7 @@ pub enum Error { )] ReservedMetaKey { key: String }, - #[error("container build exited with status {status}. To reproduce manually:\n docker run --rm -v {mount}:/source {image} contract build {args}")] + #[error("container build exited with status {status}. To reproduce manually:\n docker run --rm -v {mount}:/source {image} {args}")] ContainerExit { status: i64, image: String, From 196405d5fb1b756275c6f4c6e7ba0bd8e115958c Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 15:36:26 -0700 Subject: [PATCH 04/58] Probe container cli version for --optimize syntax. --- .../src/commands/contract/build/verifiable.rs | 162 ++++++++++++++++-- 1 file changed, 148 insertions(+), 14 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 2c77226701..6d4b2ae6c3 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -30,6 +30,12 @@ const HUB_TAGS_URL: &str = "https://hub.docker.com/v2/repositories/stellar/stellar-cli/tags/?page_size=100"; const RESERVED_META_KEYS: &[&str] = &["bldimg", "source_rev", "bldopt"]; +/// First cli release that accepts `--optimize=false` as an explicit value +/// (added by commit `b17d3f0b`). Containers older than this only accept bare +/// `--optimize`; we probe the container's `stellar version --only-version` to +/// pick the right syntax for `--optimize=false`. +const OPTIMIZE_NEW_SYNTAX_MIN: &str = "26.1.0"; + #[derive(thiserror::Error, Debug)] pub enum Error { #[error("⛔ failed to connect to docker: {0}")] @@ -119,7 +125,17 @@ pub async fn run( .map_err(Error::DockerConnection)?; let image_ref = resolve_image(cmd, &docker, print).await?; - let (forwarded_args, bldopts) = build_forwarded_args(cmd, &workspace_root); + // Only probe the container's cli version when we need to pick between + // `--optimize=false` (new syntax) and not-forwarded-at-all (old default). + // Bare `--optimize` is universally accepted, so the true path skips this. + let supports_explicit_optimize_false = if cmd.build_args.optimize { + true + } else { + probe_supports_optimize_false_syntax(&image_ref, &docker, print).await + }; + + let (forwarded_args, bldopts) = + build_forwarded_args(cmd, &workspace_root, supports_explicit_optimize_false); let metadata_args = build_metadata_args(&image_ref, &source_rev, &bldopts); let container_cmd_args = compose_container_args(&forwarded_args, &metadata_args); @@ -196,7 +212,16 @@ fn git_source_rev(workspace_root: &Path, print: &Print) -> Result /// becomes one bldopt entry so a verifier can replay the same invocation. /// `--locked` is always present. `manifest_path` (when set) is recorded /// relative to the workspace root so it's valid inside `/source`. -fn build_forwarded_args(cmd: &Cmd, workspace_root: &Path) -> (Vec, Vec) { +/// +/// `supports_explicit_optimize_false`: whether the container's cli accepts +/// `--optimize=false`. When false, the optimize=false case records the flag +/// in bldopt but does not forward it (the older container's cli default of +/// `false` already produces the desired state). +fn build_forwarded_args( + cmd: &Cmd, + workspace_root: &Path, + supports_explicit_optimize_false: bool, +) -> (Vec, Vec) { let mut forwarded: Vec = Vec::new(); let mut bldopts: Vec = Vec::new(); @@ -235,7 +260,14 @@ fn build_forwarded_args(cmd: &Cmd, workspace_root: &Path) -> (Vec, Vec (String, String (available_for_cli, all_grouped) } +/// Probe the container's `stellar` binary for its self-reported version with +/// `stellar version --only-version`. Returns true if the parsed version is +/// at or above the cutoff where `--optimize=false` was accepted. On any +/// probe failure (network, unparseable output, missing subcommand), returns +/// false — the conservative assumption that the container is old. +async fn probe_supports_optimize_false_syntax( + image_ref: &str, + docker: &Docker, + print: &Print, +) -> bool { + match probe_cli_version(image_ref, docker).await { + Ok(v) => { + let cutoff = Version::parse(OPTIMIZE_NEW_SYNTAX_MIN).unwrap(); + v >= cutoff + } + Err(e) => { + print.warnln(format!( + "could not probe container cli version ({e}); assuming pre-{OPTIMIZE_NEW_SYNTAX_MIN} syntax" + )); + false + } + } +} + +async fn probe_cli_version(image_ref: &str, docker: &Docker) -> Result { + let config = ContainerCreateBody { + image: Some(image_ref.to_string()), + cmd: Some(vec!["version".to_string(), "--only-version".to_string()]), + attach_stdout: Some(true), + attach_stderr: Some(true), + host_config: Some(HostConfig { + auto_remove: Some(true), + ..Default::default() + }), + ..Default::default() + }; + let created = docker + .create_container(None::, config) + .await?; + let attached = docker + .attach_container( + &created.id, + Some(AttachContainerOptions { + stdout: true, + stderr: true, + stream: true, + ..Default::default() + }), + ) + .await?; + docker + .start_container(&created.id, None::) + .await?; + + let mut stdout = String::new(); + let mut output = attached.output; + while let Some(chunk) = output.next().await { + if let Ok(bollard::container::LogOutput::StdOut { message }) = chunk { + stdout.push_str(&String::from_utf8_lossy(&message)); + } + } + + let mut wait = docker.wait_container(&created.id, None::); + while wait.next().await.is_some() {} + + Version::parse(stdout.trim()) + .map_err(|e| Error::TagListUnavailable(format!("unparseable version {stdout:?}: {e}"))) +} + async fn run_in_container( image_ref: &str, workspace_root: &Path, @@ -576,9 +677,16 @@ mod tests { #[test] fn build_forwarded_args_defaults() { let cmd = Cmd::default(); - let (forwarded, bldopts) = build_forwarded_args(&cmd, ws()); - assert_eq!(forwarded, vec!["--locked".to_string()]); - assert_eq!(bldopts, vec!["--locked".to_string()]); + let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), true); + // Default optimize=true → bare `--optimize` recorded + forwarded. + assert_eq!( + forwarded, + vec!["--locked".to_string(), "--optimize".to_string()] + ); + assert_eq!( + bldopts, + vec!["--locked".to_string(), "--optimize".to_string()] + ); } #[test] @@ -588,7 +696,7 @@ mod tests { package: Some("contract-a".to_string()), ..Cmd::default() }; - let (forwarded, bldopts) = build_forwarded_args(&cmd, ws()); + let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), true); assert!(forwarded.contains(&"--features=a,b".to_string())); assert!(forwarded.contains(&"--package=contract-a".to_string())); assert!(bldopts.contains(&"--features=a,b".to_string())); @@ -597,7 +705,7 @@ mod tests { } #[test] - fn build_forwarded_args_records_meta_optimize_and_manifest() { + fn build_forwarded_args_records_meta_and_manifest() { let cmd = Cmd { manifest_path: Some(PathBuf::from("/tmp/ws/contracts/add/Cargo.toml")), build_args: super::super::BuildArgs { @@ -605,23 +713,49 @@ mod tests { ("home_domain".to_string(), "fnando.com".to_string()), ("author".to_string(), "alice".to_string()), ], - optimize: false, + optimize: true, }, ..Cmd::default() }; - let (forwarded, bldopts) = build_forwarded_args(&cmd, ws()); + let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), true); assert!(forwarded.contains(&"--meta=home_domain=fnando.com".to_string())); assert!(forwarded.contains(&"--meta=author=alice".to_string())); - assert!(forwarded.contains(&"--optimize=false".to_string())); assert!(forwarded.contains(&"--manifest-path=contracts/add/Cargo.toml".to_string())); - // Same set is captured into bldopts so a verifier can replay every - // build-affecting flag. assert!(bldopts.contains(&"--meta=home_domain=fnando.com".to_string())); assert!(bldopts.contains(&"--meta=author=alice".to_string())); - assert!(bldopts.contains(&"--optimize=false".to_string())); assert!(bldopts.contains(&"--manifest-path=contracts/add/Cargo.toml".to_string())); } + #[test] + fn build_forwarded_args_optimize_false_new_container() { + let cmd = Cmd { + build_args: super::super::BuildArgs { + meta: vec![], + optimize: false, + }, + ..Cmd::default() + }; + let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), true); + assert!(forwarded.contains(&"--optimize=false".to_string())); + assert!(bldopts.contains(&"--optimize=false".to_string())); + } + + #[test] + fn build_forwarded_args_optimize_false_old_container() { + let cmd = Cmd { + build_args: super::super::BuildArgs { + meta: vec![], + optimize: false, + }, + ..Cmd::default() + }; + let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), false); + // Old container's default is already false; record nothing. + // Passing `--optimize=false` to a pre-26.1.0 cli would fail. + assert!(!forwarded.iter().any(|a| a.starts_with("--optimize"))); + assert!(!bldopts.iter().any(|a| a.starts_with("--optimize"))); + } + #[test] fn build_metadata_args_orders_keys() { let m = build_metadata_args( From c81c6d578ab5548a2f0510e9719aefdf3581fa04 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 16:19:24 -0700 Subject: [PATCH 05/58] Add SEP-58 source-id flags to --verifiable. --- FULL_HELP_DOCS.md | 4 + cmd/crates/soroban-test/tests/it/build.rs | 178 ++++++-- .../src/commands/contract/build.rs | 46 ++ .../src/commands/contract/build/verifiable.rs | 397 +++++++++++++++--- 4 files changed, 548 insertions(+), 77 deletions(-) diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index f0ca271564..ba4ec18ac6 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -399,6 +399,10 @@ 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_rev`, `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-repo ` — SEP-58 source identification: HTTPS URL (or `github:user/repo`) of the source repository. Must be passed together with `--source-rev` +- `--source-rev ` — SEP-58 source identification: 40-char SHA-1 of the source commit. The local workspace must be a git repo at this exact SHA with a clean working tree. Must be passed together with `--source-repo` +- `--tarball-url ` — SEP-58 source identification: URL where the source tarball can be downloaded +- `--tarball-sha256 ` — SEP-58 source identification: SHA-256 of the source tarball bytes ## `stellar contract extend` diff --git a/cmd/crates/soroban-test/tests/it/build.rs b/cmd/crates/soroban-test/tests/it/build.rs index 5936bfeacf..bb41346a3c 100644 --- a/cmd/crates/soroban-test/tests/it/build.rs +++ b/cmd/crates/soroban-test/tests/it/build.rs @@ -994,6 +994,41 @@ fn build_always_injects_cli_version() { ); } +const ZERO_DIGEST: &str = + "docker.io/stellar/stellar-cli@sha256:0000000000000000000000000000000000000000000000000000000000000000"; + +// Convenience: drive a git command in a fixture directory. +fn git_in(dir: &Path, args: &[&str]) { + std::process::Command::new("git") + .args(args) + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .status() + .unwrap(); +} + +// Init a tempdir copy of the workspace fixture and return the workspace path. +fn fresh_workspace() -> (TempDir, PathBuf) { + let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/workspace"); + let temp = TempDir::new().unwrap(); + fs_extra::dir::copy(&fixture_path, temp.path(), &CopyOptions::new()).unwrap(); + let workspace = temp.path().join("workspace"); + (temp, workspace) +} + +fn git_head(dir: &Path) -> String { + let out = std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(dir) + .output() + .unwrap(); + String::from_utf8(out.stdout).unwrap().trim().to_string() +} + // `--verifiable` cannot accept reserved `--meta` keys that the cli writes itself. #[test] fn verifiable_meta_conflict_errors() { @@ -1007,7 +1042,9 @@ fn verifiable_meta_conflict_errors() { .arg("build") .arg("--verifiable") .arg("--image") - .arg("docker.io/stellar/stellar-cli@sha256:0000000000000000000000000000000000000000000000000000000000000000") + .arg(ZERO_DIGEST) + .arg("--tarball-url") + .arg("https://example.com/foo.tar.gz") .arg("--meta") .arg("bldimg=not-allowed") .assert() @@ -1015,7 +1052,7 @@ fn verifiable_meta_conflict_errors() { .stderr(predicate::str::contains("reserved key: bldimg")); } -// `--image` must be content-addressed; tag-only refs are rejected. +// `--image` is validated against the SEP-58 bldimg regex; tag-only refs fail. #[test] fn verifiable_image_must_be_digest_pinned() { let sandbox = TestEnv::default(); @@ -1029,39 +1066,118 @@ fn verifiable_image_must_be_digest_pinned() { .arg("--verifiable") .arg("--image") .arg("docker.io/stellar/stellar-cli:latest") + .arg("--tarball-url") + .arg("https://example.com/foo.tar.gz") .assert() .failure() - .stderr(predicate::str::contains("must be digest-pinned")); + .stderr(predicate::str::contains("bldimg format")); } -// A dirty git tree breaks the verifiability property because `source_rev` would -// record a commit whose bytes don't match the produced WASM. Hard fail. +// SEP-58 bldimg requires an explicit registry host (e.g. `docker.io/...`). +// Implicit Docker-Hub-style short refs are rejected. #[test] -fn verifiable_dirty_tree_errors() { +fn verifiable_image_requires_explicit_registry_host() { let sandbox = TestEnv::default(); let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let fixture_path = cargo_dir.join("tests/fixtures/workspace"); - let temp = TempDir::new().unwrap(); - let dir_path = temp.path(); - fs_extra::dir::copy(fixture_path, dir_path, &CopyOptions::new()).unwrap(); - let workspace = dir_path.join("workspace"); - - // Bootstrap a clean git tree at the workspace root, then dirty it so the - // verifiable path's dirty-check trips before docker is touched. - let git = |args: &[&str]| { - std::process::Command::new("git") - .args(args) - .current_dir(&workspace) - .env("GIT_AUTHOR_NAME", "Test") - .env("GIT_AUTHOR_EMAIL", "test@example.com") - .env("GIT_COMMITTER_NAME", "Test") - .env("GIT_COMMITTER_EMAIL", "test@example.com") - .status() - .unwrap(); - }; - git(&["init", "-q", "-b", "main"]); - git(&["add", "-A"]); - git(&["commit", "-q", "-m", "init"]); + let fixture_path = cargo_dir.join("tests/fixtures/workspace/contracts/add"); + + let short_ref = format!("stellar/stellar-cli@sha256:{}", "0".repeat(64)); + + sandbox + .new_assert_cmd("contract") + .current_dir(fixture_path) + .arg("build") + .arg("--verifiable") + .arg("--image") + .arg(short_ref) + .arg("--tarball-url") + .arg("https://example.com/foo.tar.gz") + .assert() + .failure() + .stderr(predicate::str::contains("bldimg format")); +} + +// `--verifiable` without any source-identification flag must error. +#[test] +fn verifiable_requires_source_id() { + let sandbox = TestEnv::default(); + let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/workspace/contracts/add"); + + sandbox + .new_assert_cmd("contract") + .current_dir(fixture_path) + .arg("build") + .arg("--verifiable") + .arg("--image") + .arg(ZERO_DIGEST) + .assert() + .failure() + .stderr(predicate::str::contains("source-identification")); +} + +// `--source-rev` value must match the 40-hex regex. +#[test] +fn verifiable_source_rev_format_errors() { + let sandbox = TestEnv::default(); + let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/workspace/contracts/add"); + + sandbox + .new_assert_cmd("contract") + .current_dir(fixture_path) + .arg("build") + .arg("--verifiable") + .arg("--image") + .arg(ZERO_DIGEST) + .arg("--source-repo") + .arg("https://github.com/foo/bar") + .arg("--source-rev") + .arg("not-a-sha") + .assert() + .failure() + .stderr(predicate::str::contains("source_rev format")); +} + +// `--source-rev` is cross-checked against local git HEAD; a mismatch is a hard +// fail before docker is touched. +#[test] +fn verifiable_source_rev_must_match_head() { + 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"]); + + let bogus = "a".repeat(40); + + sandbox + .new_assert_cmd("contract") + .current_dir(workspace.join("contracts").join("add")) + .arg("build") + .arg("--verifiable") + .arg("--image") + .arg(ZERO_DIGEST) + .arg("--source-repo") + .arg("https://github.com/foo/bar") + .arg("--source-rev") + .arg(bogus) + .assert() + .failure() + .stderr(predicate::str::contains("does not match local HEAD")); +} + +// A dirty git tree under `--source-rev` is a hard fail (the recorded rev would +// not describe the bytes built). +#[test] +fn verifiable_dirty_tree_errors_with_source_rev() { + 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"]); + let head = git_head(&workspace); + // Dirty the tree after committing so HEAD matches but status is non-empty. std::fs::write(workspace.join("dirty.txt"), b"uncommitted").unwrap(); sandbox @@ -1070,7 +1186,11 @@ fn verifiable_dirty_tree_errors() { .arg("build") .arg("--verifiable") .arg("--image") - .arg("docker.io/stellar/stellar-cli@sha256:0000000000000000000000000000000000000000000000000000000000000000") + .arg(ZERO_DIGEST) + .arg("--source-repo") + .arg("https://github.com/foo/bar") + .arg("--source-rev") + .arg(head) .assert() .failure() .stderr(predicate::str::contains("dirty").or(predicate::str::contains("clean tree"))); diff --git a/cmd/soroban-cli/src/commands/contract/build.rs b/cmd/soroban-cli/src/commands/contract/build.rs index c6f1173f74..7d912c7084 100644 --- a/cmd/soroban-cli/src/commands/contract/build.rs +++ b/cmd/soroban-cli/src/commands/contract/build.rs @@ -111,6 +111,48 @@ pub struct Cmd { #[arg(long, requires = "verifiable", help_heading = "Verifiable")] pub image: Option, + /// SEP-58 source identification: HTTPS URL (or `github:user/repo`) of the + /// source repository. Must be passed together with `--source-rev`. + #[arg( + long, + requires = "verifiable", + requires = "source_rev", + conflicts_with_all = ["tarball_url", "tarball_sha256"], + help_heading = "Verifiable" + )] + pub source_repo: Option, + + /// SEP-58 source identification: 40-char SHA-1 of the source commit. The + /// local workspace must be a git repo at this exact SHA with a clean + /// working tree. Must be passed together with `--source-repo`. + #[arg( + long, + requires = "verifiable", + requires = "source_repo", + conflicts_with_all = ["tarball_url", "tarball_sha256"], + help_heading = "Verifiable" + )] + pub source_rev: Option, + + /// SEP-58 source identification: URL where the source tarball can be + /// downloaded. + #[arg( + long, + requires = "verifiable", + conflicts_with_all = ["source_repo", "source_rev"], + help_heading = "Verifiable" + )] + pub tarball_url: Option, + + /// SEP-58 source identification: SHA-256 of the source tarball bytes. + #[arg( + long, + requires = "verifiable", + conflicts_with_all = ["source_repo", "source_rev"], + help_heading = "Verifiable" + )] + pub tarball_sha256: Option, + #[command(flatten)] pub container_args: container::shared::Args, @@ -245,6 +287,10 @@ impl Default for Cmd { print_commands_only: false, verifiable: false, image: None, + source_repo: None, + source_rev: None, + tarball_url: None, + tarball_sha256: None, container_args: container::shared::Args { docker_host: None }, build_args: BuildArgs::default(), } diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 6d4b2ae6c3..adf7233ec0 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -44,8 +44,8 @@ pub enum Error { #[error(transparent)] Bollard(#[from] bollard::errors::Error), - #[error("--image must be digest-pinned (got {value}); SEP-58 requires content-addressed images. Pass docker.io/stellar/stellar-cli@sha256:")] - ImageNotDigestPinned { value: String }, + #[error("--image value {value:?} does not match the SEP-58 bldimg format `/@sha256:<64-hex>`. Examples: docker.io/stellar/stellar-cli@sha256:<64-hex>, localhost:5000/foo@sha256:<64-hex>. Tag-only refs and implicit Docker-Hub short refs are not accepted.")] + BldimgFormat { value: String }, #[error("could not determine the running rustc version: {0}")] RustcVersion(String), @@ -74,7 +74,7 @@ pub enum Error { }, #[error( - "git working tree at {path} is dirty. Verifiable builds require a clean tree so the recorded source_rev matches the WASM bytes. Commit or stash your changes and try again." + "git working tree at {path} is dirty. --source-rev requires a clean tree so the recorded source_rev matches the WASM bytes. Commit or stash your changes and try again." )] GitDirty { path: PathBuf }, @@ -83,6 +83,27 @@ pub enum Error { )] ReservedMetaKey { key: String }, + #[error("--verifiable requires a SEP-58 source-identification combination. Pass one of: (--source-repo + --source-rev), (--tarball-url and/or --tarball-sha256).")] + MissingSourceId, + + #[error("--source-rev value {value:?} does not match the SEP-58 source_rev format `^[0-9a-f]{{40}}$` (full 40-char SHA-1 of the source commit).")] + SourceRevFormat { value: String }, + + #[error("--source-repo value {value:?} does not match the SEP-58 source_repo format `^(https?://\\S+|github:[^/\\s]+/[^/\\s]+)$`.")] + SourceRepoFormat { value: String }, + + #[error("--tarball-url value {value:?} does not match the SEP-58 tarball_url format `^https?://\\S+$`.")] + TarballUrlFormat { value: String }, + + #[error("--tarball-sha256 value {value:?} does not match the SEP-58 tarball_sha256 format `^[0-9a-f]{{64}}$`.")] + TarballSha256Format { value: String }, + + #[error("--source-rev requires a git workspace at {path}; `git rev-parse HEAD` failed there.")] + SourceRevNotGitRepo { path: PathBuf }, + + #[error("--source-rev {claimed} does not match local HEAD {head}. Commit, switch, or pass the correct rev.")] + SourceRevHeadMismatch { claimed: String, head: String }, + #[error("container build exited with status {status}. To reproduce manually:\n docker run --rm -v {mount}:/source {image} {args}")] ContainerExit { status: i64, @@ -104,8 +125,8 @@ pub async fn run( } } if let Some(img) = &cmd.image { - if !img.contains("@sha256:") { - return Err(Error::ImageNotDigestPinned { value: img.clone() }.into()); + if !bldimg_regex().is_match(img) { + return Err(Error::BldimgFormat { value: img.clone() }.into()); } } @@ -113,9 +134,11 @@ pub async fn run( print.infoln("--verifiable implies --locked"); } - // Stage 2: local filesystem + git, no network. + // Stage 2: local filesystem + git, no network. Resolve the workspace root + // first so the (optional) `--source-rev` git cross-check has a path to + // anchor on. let workspace_root = resolve_workspace_root(cmd)?; - let source_rev = git_source_rev(&workspace_root, print)?; + let source_ids = validate_source_ids(cmd, &workspace_root)?; // Stage 3: docker. let docker = cmd @@ -136,7 +159,7 @@ pub async fn run( let (forwarded_args, bldopts) = build_forwarded_args(cmd, &workspace_root, supports_explicit_optimize_false); - let metadata_args = build_metadata_args(&image_ref, &source_rev, &bldopts); + let metadata_args = build_metadata_args(&image_ref, &source_ids, &bldopts); let container_cmd_args = compose_container_args(&forwarded_args, &metadata_args); run_in_container( @@ -162,32 +185,91 @@ fn resolve_workspace_root(cmd: &Cmd) -> Result { Ok(md.workspace_root.into_std_path_buf()) } -fn git_source_rev(workspace_root: &Path, print: &Print) -> Result { - // Probe with rev-parse first to detect "not a git repo". - let rev = Command::new("git") +/// Source-identification fields, gathered from the corresponding CLI flags +/// after validation. Each is `Some` only when the user passed the flag and the +/// value matched the SEP-58 format regex. The four fields cannot all be +/// `None` — `validate_source_ids` rejects that case. +#[derive(Debug, Default, Clone)] +struct SourceIds { + source_repo: Option, + source_rev: Option, + tarball_url: Option, + tarball_sha256: Option, +} + +fn validate_source_ids(cmd: &Cmd, workspace_root: &Path) -> Result { + let ids = SourceIds { + source_repo: cmd.source_repo.clone(), + source_rev: cmd.source_rev.clone(), + tarball_url: cmd.tarball_url.clone(), + tarball_sha256: cmd.tarball_sha256.clone(), + }; + + if ids.source_repo.is_none() + && ids.source_rev.is_none() + && ids.tarball_url.is_none() + && ids.tarball_sha256.is_none() + { + return Err(Error::MissingSourceId); + } + + if let Some(v) = &ids.source_rev { + if !source_rev_regex().is_match(v) { + return Err(Error::SourceRevFormat { value: v.clone() }); + } + } + + if let Some(v) = &ids.source_repo { + if !source_repo_regex().is_match(v) { + return Err(Error::SourceRepoFormat { value: v.clone() }); + } + } + + if let Some(v) = &ids.tarball_url { + if !tarball_url_regex().is_match(v) { + return Err(Error::TarballUrlFormat { value: v.clone() }); + } + } + + if let Some(v) = &ids.tarball_sha256 { + if !tarball_sha256_regex().is_match(v) { + return Err(Error::TarballSha256Format { value: v.clone() }); + } + } + + if let Some(claimed) = &ids.source_rev { + cross_check_source_rev_against_git(workspace_root, claimed)?; + } + + Ok(ids) +} + +fn cross_check_source_rev_against_git(workspace_root: &Path, claimed: &str) -> Result<(), Error> { + let rev_out = Command::new("git") .arg("-C") .arg(workspace_root) .arg("rev-parse") .arg("HEAD") - .output(); - let rev = match rev { - Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(), - Ok(_) => { - print.warnln(format!( - "{} is not a git repository; recording empty source_rev (verifiability is degraded).", - workspace_root.display() - )); - return Ok(String::new()); - } - Err(e) => { - return Err(Error::GitInvoke { - path: workspace_root.to_path_buf(), - source: e, - }) - } - }; + .output() + .map_err(|e| Error::GitInvoke { + path: workspace_root.to_path_buf(), + source: e, + })?; + + if !rev_out.status.success() { + return Err(Error::SourceRevNotGitRepo { + path: workspace_root.to_path_buf(), + }); + } + + let head = String::from_utf8_lossy(&rev_out.stdout).trim().to_string(); + if head != claimed { + return Err(Error::SourceRevHeadMismatch { + claimed: claimed.to_string(), + head, + }); + } - // Dirty check. let status = Command::new("git") .arg("-C") .arg(workspace_root) @@ -198,13 +280,35 @@ fn git_source_rev(workspace_root: &Path, print: &Print) -> Result path: workspace_root.to_path_buf(), source: e, })?; + if !status.stdout.is_empty() { return Err(Error::GitDirty { path: workspace_root.to_path_buf(), }); } - Ok(rev) + Ok(()) +} + +fn bldimg_regex() -> Regex { + Regex::new(r"^(?:localhost(?::\d+)?|[^\s@/]*[.:][^\s@/]*)/[^\s@]+@sha256:[0-9a-f]{64}$") + .unwrap() +} + +fn source_rev_regex() -> Regex { + Regex::new(r"^[0-9a-f]{40}$").unwrap() +} + +fn source_repo_regex() -> Regex { + Regex::new(r"^(https?://\S+|github:[^/\s]+/[^/\s]+)$").unwrap() +} + +fn tarball_url_regex() -> Regex { + Regex::new(r"^https?://\S+$").unwrap() +} + +fn tarball_sha256_regex() -> Regex { + Regex::new(r"^[0-9a-f]{64}$").unwrap() } /// The flags forwarded to the container's `stellar contract build`, plus the @@ -274,16 +378,33 @@ fn build_forwarded_args( (forwarded, bldopts) } -fn build_metadata_args(image_ref: &str, source_rev: &str, bldopts: &[String]) -> Vec { +fn build_metadata_args(image_ref: &str, ids: &SourceIds, bldopts: &[String]) -> Vec { let mut out = Vec::new(); - for (k, v) in [("bldimg", image_ref), ("source_rev", source_rev)] { + + let push = |out: &mut Vec, key: &str, val: &str| { out.push("--meta".to_string()); - out.push(format!("{k}={v}")); + out.push(format!("{key}={val}")); + }; + + push(&mut out, "bldimg", image_ref); + + if let Some(v) = &ids.source_repo { + push(&mut out, "source_repo", v); + } + if let Some(v) = &ids.source_rev { + push(&mut out, "source_rev", v); } + if let Some(v) = &ids.tarball_url { + push(&mut out, "tarball_url", v); + } + if let Some(v) = &ids.tarball_sha256 { + push(&mut out, "tarball_sha256", v); + } + for o in bldopts { - out.push("--meta".to_string()); - out.push(format!("bldopt={o}")); + push(&mut out, "bldopt", o); } + out } @@ -296,8 +417,8 @@ fn compose_container_args(forwarded: &[String], metadata: &[String]) -> Vec Result { if let Some(s) = &cmd.image { - if !s.contains("@sha256:") { - return Err(Error::ImageNotDigestPinned { value: s.clone() }); + if !bldimg_regex().is_match(s) { + return Err(Error::BldimgFormat { value: s.clone() }); } return Ok(s.clone()); } @@ -756,25 +877,205 @@ mod tests { assert!(!bldopts.iter().any(|a| a.starts_with("--optimize"))); } + fn pairs(args: &[String]) -> Vec<(&str, &str)> { + args.chunks(2) + .map(|c| (c[0].as_str(), c[1].as_str())) + .collect() + } + #[test] - fn build_metadata_args_orders_keys() { + fn build_metadata_args_source_repo_and_rev() { + let ids = SourceIds { + source_repo: Some("https://github.com/foo/bar".to_string()), + source_rev: Some("a".repeat(40)), + tarball_url: None, + tarball_sha256: None, + }; let m = build_metadata_args( "docker.io/stellar/stellar-cli@sha256:abc", - "deadbeef", + &ids, &["--locked".to_string(), "--features=a".to_string()], ); - // bldimg, source_rev, then bldopts in order. - let pairs: Vec<(&str, &str)> = m - .chunks(2) - .map(|c| (c[0].as_str(), c[1].as_str())) - .collect(); + let p = pairs(&m); + // bldimg first; source-ids only for what's set; bldopts last. assert_eq!( - pairs[0], + p[0], ("--meta", "bldimg=docker.io/stellar/stellar-cli@sha256:abc") ); - assert_eq!(pairs[1], ("--meta", "source_rev=deadbeef")); - assert_eq!(pairs[2], ("--meta", "bldopt=--locked")); - assert_eq!(pairs[3], ("--meta", "bldopt=--features=a")); + assert_eq!(p[1], ("--meta", "source_repo=https://github.com/foo/bar")); + assert_eq!(p[2].0, "--meta"); + assert!(p[2].1.starts_with("source_rev=")); + assert_eq!(p[3], ("--meta", "bldopt=--locked")); + assert_eq!(p[4], ("--meta", "bldopt=--features=a")); + // No tarball entries emitted when those fields are None. + assert!(!m.iter().any(|s| s.starts_with("tarball_"))); + } + + #[test] + fn build_metadata_args_tarball_url_only() { + let ids = SourceIds { + tarball_url: Some("https://example.com/foo.tar.gz".to_string()), + ..SourceIds::default() + }; + let m = build_metadata_args("docker.io/stellar/stellar-cli@sha256:abc", &ids, &[]); + assert!(m + .iter() + .any(|s| s == "tarball_url=https://example.com/foo.tar.gz")); + assert!(!m.iter().any(|s| s.starts_with("source_"))); + assert!(!m.iter().any(|s| s.starts_with("tarball_sha256="))); + } + + #[test] + fn build_metadata_args_tarball_pair() { + let ids = SourceIds { + tarball_url: Some("https://example.com/foo.tar.gz".to_string()), + tarball_sha256: Some("f".repeat(64)), + ..SourceIds::default() + }; + let m = build_metadata_args("docker.io/stellar/stellar-cli@sha256:abc", &ids, &[]); + assert!(m + .iter() + .any(|s| s == "tarball_url=https://example.com/foo.tar.gz")); + assert!(m + .iter() + .any(|s| s == &format!("tarball_sha256={}", "f".repeat(64)))); + } + + #[test] + fn validate_source_ids_missing_all_errors() { + let cmd = Cmd::default(); + let err = validate_source_ids(&cmd, ws()).unwrap_err(); + assert!(matches!(err, Error::MissingSourceId)); + } + + #[test] + fn validate_source_ids_rejects_bad_source_rev_format() { + let cmd = Cmd { + source_repo: Some("https://github.com/foo/bar".to_string()), + source_rev: Some("not-a-sha".to_string()), + ..Cmd::default() + }; + let err = validate_source_ids(&cmd, ws()).unwrap_err(); + assert!(matches!(err, Error::SourceRevFormat { .. })); + } + + #[test] + fn validate_source_ids_rejects_bad_source_repo_format() { + let cmd = Cmd { + source_repo: Some("foo/bar".to_string()), // missing scheme + source_rev: Some("a".repeat(40)), + ..Cmd::default() + }; + let err = validate_source_ids(&cmd, ws()).unwrap_err(); + assert!(matches!(err, Error::SourceRepoFormat { .. })); + } + + #[test] + fn validate_source_ids_rejects_bad_tarball_url() { + let cmd = Cmd { + tarball_url: Some("ftp://example.com/foo.tar.gz".to_string()), + ..Cmd::default() + }; + let err = validate_source_ids(&cmd, ws()).unwrap_err(); + assert!(matches!(err, Error::TarballUrlFormat { .. })); + } + + #[test] + fn validate_source_ids_rejects_short_tarball_sha256() { + let cmd = Cmd { + tarball_sha256: Some("abc".to_string()), + ..Cmd::default() + }; + let err = validate_source_ids(&cmd, ws()).unwrap_err(); + assert!(matches!(err, Error::TarballSha256Format { .. })); + } + + #[test] + fn validate_source_ids_accepts_tarball_url_alone() { + let cmd = Cmd { + tarball_url: Some("https://example.com/foo.tar.gz".to_string()), + ..Cmd::default() + }; + let ids = validate_source_ids(&cmd, ws()).unwrap(); + assert_eq!( + ids.tarball_url.as_deref(), + Some("https://example.com/foo.tar.gz") + ); + assert!(ids.source_repo.is_none()); + assert!(ids.source_rev.is_none()); + assert!(ids.tarball_sha256.is_none()); + } + + #[test] + fn validate_source_ids_accepts_tarball_sha256_alone() { + let cmd = Cmd { + tarball_sha256: Some("f".repeat(64)), + ..Cmd::default() + }; + let ids = validate_source_ids(&cmd, ws()).unwrap(); + assert_eq!( + ids.tarball_sha256.as_deref(), + Some("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") + ); + } + + #[test] + fn bldimg_regex_accepts_docker_hub_full_ref() { + assert!(bldimg_regex().is_match(&format!( + "docker.io/stellar/stellar-cli@sha256:{}", + "a".repeat(64) + ))); + } + + #[test] + fn bldimg_regex_accepts_localhost_registry() { + assert!(bldimg_regex().is_match(&format!("localhost:5000/foo@sha256:{}", "0".repeat(64)))); + } + + #[test] + fn bldimg_regex_rejects_implicit_hub_short_ref() { + // Implicit Docker Hub short ref: no registry host prefix. + assert!(!bldimg_regex().is_match(&format!("stellar/stellar-cli@sha256:{}", "a".repeat(64)))); + } + + #[test] + fn bldimg_regex_rejects_tag_only() { + assert!(!bldimg_regex().is_match("docker.io/stellar/stellar-cli:latest")); + } + + #[test] + fn bldimg_regex_rejects_short_sha() { + assert!(!bldimg_regex().is_match("docker.io/stellar/stellar-cli@sha256:abc")); + } + + #[test] + fn source_rev_regex_matches_40_hex() { + assert!(source_rev_regex().is_match(&"a".repeat(40))); + assert!(!source_rev_regex().is_match(&"a".repeat(39))); + assert!(!source_rev_regex().is_match(&"A".repeat(40))); // upper-case rejected + } + + #[test] + fn source_repo_regex_accepts_https_and_github_shorthand() { + assert!(source_repo_regex().is_match("https://github.com/foo/bar")); + assert!(source_repo_regex().is_match("http://example.com/foo.git")); + assert!(source_repo_regex().is_match("github:foo/bar")); + assert!(!source_repo_regex().is_match("foo/bar")); + assert!(!source_repo_regex().is_match("git@github.com:foo/bar.git")); + } + + #[test] + fn tarball_url_regex_accepts_http_only() { + assert!(tarball_url_regex().is_match("https://example.com/foo.tar.gz")); + assert!(tarball_url_regex().is_match("http://example.com/foo.tar.gz")); + assert!(!tarball_url_regex().is_match("ftp://example.com/foo.tar.gz")); + } + + #[test] + fn tarball_sha256_regex_matches_64_hex() { + assert!(tarball_sha256_regex().is_match(&"f".repeat(64))); + assert!(!tarball_sha256_regex().is_match(&"f".repeat(63))); + assert!(!tarball_sha256_regex().is_match(&"F".repeat(64))); } #[test] From e000d846069ef632f6425a6fe7b955268c42a9e2 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 16:20:26 -0700 Subject: [PATCH 06/58] Move --locked info banner after validation. --- .../src/commands/contract/build/verifiable.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index adf7233ec0..03a376fda0 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -130,16 +130,18 @@ pub async fn run( } } - if !cmd.locked { - print.infoln("--verifiable implies --locked"); - } - // Stage 2: local filesystem + git, no network. Resolve the workspace root // first so the (optional) `--source-rev` git cross-check has a path to // anchor on. let workspace_root = resolve_workspace_root(cmd)?; let source_ids = validate_source_ids(cmd, &workspace_root)?; + // Defer the info banner until every validation has passed, so it doesn't + // appear right before an error. + if !cmd.locked { + print.infoln("--verifiable implies --locked"); + } + // Stage 3: docker. let docker = cmd .container_args From 5f59fb856aaecbd56f5817ddb584492f85c4c9df Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 17:27:49 -0700 Subject: [PATCH 07/58] Group --docker-host under Verifiable on contract build. --- FULL_HELP_DOCS.md | 2 +- cmd/soroban-cli/src/commands/contract/build.rs | 9 +++++---- .../src/commands/contract/build/verifiable.rs | 6 ++++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index ba4ec18ac6..58f5340421 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -384,7 +384,6 @@ To view the commands that will be executed, without executing them, use the --pr If ommitted, wasm files are written only to the cargo target directory. - `--locked` — Assert that `Cargo.lock` will remain unchanged -- `-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 - `--optimize ` — Optimize the generated wasm. Enabled by default; pass `--optimize=false` to disable. Requires the `additional-libs` feature Default value: `true` @@ -403,6 +402,7 @@ To view the commands that will be executed, without executing them, use the --pr - `--source-rev ` — SEP-58 source identification: 40-char SHA-1 of the source commit. The local workspace must be a git repo at this exact SHA with a clean working tree. Must be passed together with `--source-repo` - `--tarball-url ` — SEP-58 source identification: URL where the source tarball can be downloaded - `--tarball-sha256 ` — SEP-58 source identification: SHA-256 of the source tarball bytes +- `-d`, `--docker-host ` — Override the default docker host used by `--verifiable` ## `stellar contract extend` diff --git a/cmd/soroban-cli/src/commands/contract/build.rs b/cmd/soroban-cli/src/commands/contract/build.rs index 7d912c7084..51829fa56f 100644 --- a/cmd/soroban-cli/src/commands/contract/build.rs +++ b/cmd/soroban-cli/src/commands/contract/build.rs @@ -20,7 +20,7 @@ use stellar_xdr::{Limited, Limits, ScMetaEntry, ScMetaV0, StringM, WriteXdr}; #[cfg(feature = "additional-libs")] use crate::commands::contract::optimize; use crate::{ - commands::{container, global, version}, + commands::{global, version}, print::Print, wasm, }; @@ -153,8 +153,9 @@ pub struct Cmd { )] pub tarball_sha256: Option, - #[command(flatten)] - pub container_args: container::shared::Args, + /// Override the default docker host used by `--verifiable`. + #[arg(short = 'd', long, env = "DOCKER_HOST", help_heading = "Verifiable")] + pub docker_host: Option, #[command(flatten)] pub build_args: BuildArgs, @@ -291,7 +292,7 @@ impl Default for Cmd { source_rev: None, tarball_url: None, tarball_sha256: None, - container_args: container::shared::Args { docker_host: None }, + docker_host: None, build_args: BuildArgs::default(), } } diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 03a376fda0..0a84f19ca5 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -143,8 +143,10 @@ pub async fn run( } // Stage 3: docker. - let docker = cmd - .container_args + let docker_args = crate::commands::container::shared::Args { + docker_host: cmd.docker_host.clone(), + }; + let docker = docker_args .connect_to_docker(print) .await .map_err(Error::DockerConnection)?; From 17c2fc8ad4c04dffd9ed41852fa8d9e7c759e39d Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 18:32:05 -0700 Subject: [PATCH 08/58] Plumb verbose flag through run_in_container. --- .../src/commands/contract/build/verifiable.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 0a84f19ca5..90fa367ecb 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -166,12 +166,17 @@ pub async fn run( let metadata_args = build_metadata_args(&image_ref, &source_ids, &bldopts); let container_cmd_args = compose_container_args(&forwarded_args, &metadata_args); + // Always stream the container's cargo output during `contract build + // --verifiable`, matching how a non-verifiable `contract build` shows + // cargo output by default. The verify-side caller gates this on + // `--verbose` because verifications are run as part of pipelines. run_in_container( &image_ref, &workspace_root, &container_cmd_args, &docker, print, + true, ) .await?; @@ -653,6 +658,7 @@ async fn run_in_container( container_cmd: &[String], docker: &Docker, print: &Print, + verbose: bool, ) -> Result<(), Error> { let bind = format!("{}:/source", workspace_root.display()); let config = ContainerCreateBody { @@ -700,8 +706,10 @@ async fn run_in_container( bollard::container::LogOutput::StdOut { message } | bollard::container::LogOutput::StdErr { message }, ) => { - let s = String::from_utf8_lossy(&message); - print.blankln(s.trim_end()); + if verbose { + let s = String::from_utf8_lossy(&message); + print.blankln(s.trim_end()); + } } Ok(_) => {} Err(e) => return Err(e.into()), From 9c3b31f86f0f23b17e192f0b00ee9d0125ea4e54 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 19:41:06 -0700 Subject: [PATCH 09/58] Capitalize info and warn messages on verifiable build. --- cmd/soroban-cli/src/commands/contract/build/verifiable.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 90fa367ecb..9d45440d91 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -139,7 +139,7 @@ pub async fn run( // Defer the info banner until every validation has passed, so it doesn't // appear right before an error. if !cmd.locked { - print.infoln("--verifiable implies --locked"); + print.infoln("Implying --locked because --verifiable was passed"); } // Stage 3: docker. @@ -600,7 +600,7 @@ async fn probe_supports_optimize_false_syntax( } Err(e) => { print.warnln(format!( - "could not probe container cli version ({e}); assuming pre-{OPTIMIZE_NEW_SYNTAX_MIN} syntax" + "Could not probe container cli version ({e}); assuming pre-{OPTIMIZE_NEW_SYNTAX_MIN} syntax" )); false } From c20a1d5f373ad4b0f9dc37616ab1c11787d12c9c Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 22:29:10 -0700 Subject: [PATCH 10/58] Rewrite docker pull status lines for clarity. --- .../src/commands/contract/build/verifiable.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 9d45440d91..71ff2f95bf 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -483,11 +483,18 @@ async fn pull_image( ); while let Some(item) = stream.try_next().await? { if let Some(status) = item.status { - if status.contains("Pulling from") - || status.contains("Digest") - || status.contains("Status") - { - print.infoln(status); + // The docker daemon emits short status lines like: + // "Pulling from " + // "Digest: sha256:" + // "Status: Image is up to date for " + // Stand-alone "Digest" reads as an orphan. Rewrite each line so + // it makes sense outside the docker-pull context. + if let Some(repo) = status.strip_prefix("Pulling from ") { + print.infoln(format!("Pulling image {repo}")); + } else if let Some(digest) = status.strip_prefix("Digest: ") { + print.infoln(format!("Image digest: {digest}")); + } else if let Some(rest) = status.strip_prefix("Status: ") { + print.infoln(format!("Image: {rest}")); } } } From 4d7fcd633d895d2eb371f7544a4307a9d4a59d48 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 23:49:54 -0700 Subject: [PATCH 11/58] Anchor verifiable build bind-mount to git root or cwd. --- .../src/commands/contract/build/verifiable.rs | 82 ++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 71ff2f95bf..1e185380c6 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -136,6 +136,16 @@ pub async fn run( let workspace_root = resolve_workspace_root(cmd)?; let source_ids = validate_source_ids(cmd, &workspace_root)?; + // Pick the anchor the container bind-mounts and the `--manifest-path` + // bldopt is relativized against. A verifier will clone source_repo (or + // extract the tarball) into a fresh tempdir and bind-mount its root, so + // the build must do the symmetric thing on the host: bind-mount the local + // clone root (where `.git` lives) or, if there's no clone, the user's + // cwd. We do NOT validate that the local clone matches `--source-repo` — + // a wrong clone produces different bytes, and verify catches that at + // byte-comparison time. + let source_root = resolve_source_root(cmd); + // Defer the info banner until every validation has passed, so it doesn't // appear right before an error. if !cmd.locked { @@ -162,7 +172,7 @@ pub async fn run( }; let (forwarded_args, bldopts) = - build_forwarded_args(cmd, &workspace_root, supports_explicit_optimize_false); + build_forwarded_args(cmd, &source_root, supports_explicit_optimize_false); let metadata_args = build_metadata_args(&image_ref, &source_ids, &bldopts); let container_cmd_args = compose_container_args(&forwarded_args, &metadata_args); @@ -172,7 +182,7 @@ pub async fn run( // `--verbose` because verifications are run as part of pipelines. run_in_container( &image_ref, - &workspace_root, + &source_root, &container_cmd_args, &docker, print, @@ -194,6 +204,34 @@ fn resolve_workspace_root(cmd: &Cmd) -> Result { Ok(md.workspace_root.into_std_path_buf()) } +/// Pick the anchor for the container bind-mount and for relativizing +/// `--manifest-path` into the recorded `bldopt`. Walk up from the user's +/// `--manifest-path` (or cwd, if no manifest_path) looking for a `.git` +/// directory; return its parent. If none is found, fall back to cwd. +/// +/// This isn't a validation step — any `.git` will do. Wrong-clone mistakes +/// are caught later by the verify-side byte comparison. +fn resolve_source_root(cmd: &Cmd) -> PathBuf { + let start = if let Some(p) = &cmd.manifest_path { + let abs = std::path::absolute(p).unwrap_or_else(|_| p.clone()); + abs.parent().map(Path::to_path_buf).unwrap_or(abs) + } else { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + }; + + let mut p = start.clone(); + loop { + if p.join(".git").exists() { + return p; + } + if !p.pop() { + break; + } + } + + std::env::current_dir().unwrap_or(start) +} + /// Source-identification fields, gathered from the corresponding CLI flags /// after validation. Each is `Some` only when the user passed the flag and the /// value matched the SEP-58 format regex. The four fields cannot all be @@ -1097,6 +1135,46 @@ mod tests { assert!(!tarball_sha256_regex().is_match(&"F".repeat(64))); } + #[test] + fn resolve_source_root_finds_git_root_from_subdir() { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + std::fs::create_dir_all(root.join(".git")).unwrap(); + let nested = root.join("contracts").join("foo"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(nested.join("Cargo.toml"), b"# placeholder").unwrap(); + + let cmd = Cmd { + manifest_path: Some(nested.join("Cargo.toml")), + ..Cmd::default() + }; + // Use canonicalize on both sides — `tempfile` returns symlinked /var + // paths on macOS while resolve_source_root walks the same prefix. + let got = std::fs::canonicalize(resolve_source_root(&cmd)).unwrap(); + let want = std::fs::canonicalize(root).unwrap(); + assert_eq!(got, want); + } + + #[test] + fn resolve_source_root_falls_back_to_cwd_without_git() { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + let nested = root.join("noisy"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(nested.join("Cargo.toml"), b"# placeholder").unwrap(); + + let cmd = Cmd { + manifest_path: Some(nested.join("Cargo.toml")), + ..Cmd::default() + }; + // No `.git` anywhere up the tree, so we fall back to cwd. We can't + // assert what cwd is in a test runner (it varies), but we can assert + // that the returned path doesn't contain the manifest's parent and + // doesn't have `.git`. That's enough to confirm fallback kicked in. + let got = resolve_source_root(&cmd); + assert!(!got.join(".git").exists()); + } + #[test] fn compose_container_args_prefixes_subcommand() { let composed = compose_container_args( From d5a7209e0da9d7cc2c19945b7b3885753a28b750 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 22 May 2026 01:02:04 -0700 Subject: [PATCH 12/58] Pull bldimg when --image is set. --- cmd/soroban-cli/src/commands/contract/build/verifiable.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 1e185380c6..66022ac826 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -467,6 +467,11 @@ pub async fn resolve_image(cmd: &Cmd, docker: &Docker, print: &Print) -> Result< if !bldimg_regex().is_match(s) { return Err(Error::BldimgFormat { value: s.clone() }); } + // Always pull, even when the digest is user-supplied. Docker requires + // the image to be locally present before `create_container` will + // accept it, and the user typically expects the cli to fetch + // whatever they asked for. + pull_image(docker, s, print).await?; return Ok(s.clone()); } From 38d09ee8eaea2701a4db7dc38ca6f11640d2efd7 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 22 May 2026 01:07:56 -0700 Subject: [PATCH 13/58] Avoid duplicate Image prefix in pull status. --- cmd/soroban-cli/src/commands/contract/build/verifiable.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 66022ac826..f9240fdd87 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -537,7 +537,10 @@ async fn pull_image( } else if let Some(digest) = status.strip_prefix("Digest: ") { print.infoln(format!("Image digest: {digest}")); } else if let Some(rest) = status.strip_prefix("Status: ") { - print.infoln(format!("Image: {rest}")); + // Docker's status text already starts with "Image …" or + // "Downloaded …", so we forward it verbatim instead of + // prepending another "Image:". + print.infoln(rest); } } } From ae73bcc6508fcbc903a3de718dbb6053363dbc3a Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 22 May 2026 01:23:18 -0700 Subject: [PATCH 14/58] Factor enforce_hardened_tree out of fix_config_permissions. --- cmd/soroban-cli/src/config/locator.rs | 84 ++++++++++++++++----------- 1 file changed, 49 insertions(+), 35 deletions(-) diff --git a/cmd/soroban-cli/src/config/locator.rs b/cmd/soroban-cli/src/config/locator.rs index e557d30740..d3e6c41f4f 100644 --- a/cmd/soroban-cli/src/config/locator.rs +++ b/cmd/soroban-cli/src/config/locator.rs @@ -623,52 +623,66 @@ impl Pwd for Args { } } -#[cfg(unix)] -fn fix_config_permissions(root: std::path::PathBuf) { - use std::os::unix::fs::PermissionsExt; - - let mut bad_dirs = Vec::new(); - let mut bad_files = Vec::new(); - let mut stack = vec![root]; - - while let Some(dir) = stack.pop() { - if let Ok(meta) = std::fs::metadata(&dir) { - if meta.permissions().mode() & 0o777 != 0o700 { - bad_dirs.push(dir.clone()); +/// Walk `root` recursively. For every regular entry whose permissions don't +/// already match the hardened mode (0o700 for dirs, 0o600 for files), set +/// them. Returns the dirs and files that were changed so callers can decide +/// whether to surface a warning. Symlinks are skipped — mode bits aren't +/// meaningful for them and `set_permissions` would follow them. +/// +/// On non-unix platforms this is a no-op; tempdirs / config dirs there rely +/// on filesystem ACLs created by the higher-level APIs. +#[allow(clippy::unnecessary_wraps)] +pub(crate) fn enforce_hardened_tree(root: &Path) -> io::Result<(Vec, Vec)> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut changed_dirs = Vec::new(); + let mut changed_files = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(p) = stack.pop() { + let Ok(meta) = std::fs::symlink_metadata(&p) else { + continue; + }; + if meta.file_type().is_symlink() { + continue; } - } - - if let Ok(entries) = std::fs::read_dir(&dir) { - for entry in entries.filter_map(Result::ok) { - let path = entry.path(); - - if path.is_dir() { - stack.push(path); - } else if let Ok(meta) = std::fs::metadata(&path) { - if meta.permissions().mode() & 0o777 != 0o600 { - bad_files.push(path); + let current = meta.permissions().mode() & 0o777; + if meta.is_dir() { + if current != 0o700 { + set_hardened_permissions(&p)?; + changed_dirs.push(p.clone()); + } + if let Ok(entries) = std::fs::read_dir(&p) { + for entry in entries.filter_map(Result::ok) { + stack.push(entry.path()); } } + } else if current != 0o600 { + set_hardened_permissions(&p)?; + changed_files.push(p); } } + Ok((changed_dirs, changed_files)) } + #[cfg(not(unix))] + { + let _ = root; + Ok((Vec::new(), Vec::new())) + } +} - let print = Print::new(false); +#[cfg(unix)] +fn fix_config_permissions(root: std::path::PathBuf) { + let Ok((dirs, files)) = enforce_hardened_tree(&root) else { + return; + }; - if !bad_dirs.is_empty() { + let print = Print::new(false); + if !dirs.is_empty() { print.warnln("Updated config directories permissions to 0700."); - - for dir in bad_dirs { - let _ = set_hardened_permissions(&dir); - } } - - if !bad_files.is_empty() { + if !files.is_empty() { print.warnln("Updated config files permissions to 0600."); - - for file in bad_files { - let _ = set_hardened_permissions(&file); - } } } From 7014661551e4f20c51c605660fa1b5c97818f679 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Tue, 16 Jun 2026 19:53:41 -0700 Subject: [PATCH 15/58] Generate source archive for verifiable builds. --- Cargo.lock | 18 +- FULL_HELP_DOCS.md | 9 +- cmd/crates/soroban-test/tests/it/build.rs | 114 +-- cmd/soroban-cli/Cargo.toml | 3 +- .../src/commands/contract/build.rs | 69 +- .../src/commands/contract/build/verifiable.rs | 840 +++++++++++++----- cmd/soroban-cli/src/config/data.rs | 18 + 7 files changed, 725 insertions(+), 346 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5c27f3f806..260212a41a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2791,7 +2791,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.4", "system-configuration", "tokio", "tower-service", @@ -4305,7 +4305,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.5.10", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -4342,7 +4342,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -5450,6 +5450,7 @@ dependencies = [ "strsim", "strum 0.17.1", "strum_macros 0.17.1", + "tar", "tempfile", "termcolor", "termcolor_output", @@ -6088,6 +6089,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "temp-dir" version = "0.1.16" diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index 58f5340421..e6af930448 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -396,12 +396,11 @@ To view the commands that will be executed, without executing them, use the --pr ###### **Verifiable:** -- `--verifiable` — Build inside a trusted Docker container and record SEP-58 metadata (`bldimg`, `source_rev`, `bldopt`) so the resulting WASM can be reproduced and verified by third parties. Implies `--locked`. Requires a clean git working tree +- `--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-repo ` — SEP-58 source identification: HTTPS URL (or `github:user/repo`) of the source repository. Must be passed together with `--source-rev` -- `--source-rev ` — SEP-58 source identification: 40-char SHA-1 of the source commit. The local workspace must be a git repo at this exact SHA with a clean working tree. Must be passed together with `--source-repo` -- `--tarball-url ` — SEP-58 source identification: URL where the source tarball can be downloaded -- `--tarball-sha256 ` — SEP-58 source identification: SHA-256 of the source tarball bytes +- `--source-sha256 ` — SEP-58 source identification: SHA-256 of the source archive/tree (recorded as the `source_sha256` meta entry). Required with `--verifiable` unless `--archive` is used, which generates the archive and computes this for you +- `--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` +- `--archive ` — Generate a source archive for the verifiable build, then build from it and record its SHA-256 as the SEP-58 `source_sha256` meta entry. Pass a path to choose where the gzipped tarball is written; with no path it goes to the data dir's `archives/`. In a git repo the archive is `git archive HEAD`; otherwise the working directory is archived minus a built-in denylist (.git, .svn, .hg, target/, node_modules/, .DS_Store) - `-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 bb41346a3c..9733d37fb6 100644 --- a/cmd/crates/soroban-test/tests/it/build.rs +++ b/cmd/crates/soroban-test/tests/it/build.rs @@ -1020,15 +1020,6 @@ fn fresh_workspace() -> (TempDir, PathBuf) { (temp, workspace) } -fn git_head(dir: &Path) -> String { - let out = std::process::Command::new("git") - .args(["rev-parse", "HEAD"]) - .current_dir(dir) - .output() - .unwrap(); - String::from_utf8(out.stdout).unwrap().trim().to_string() -} - // `--verifiable` cannot accept reserved `--meta` keys that the cli writes itself. #[test] fn verifiable_meta_conflict_errors() { @@ -1043,8 +1034,8 @@ fn verifiable_meta_conflict_errors() { .arg("--verifiable") .arg("--image") .arg(ZERO_DIGEST) - .arg("--tarball-url") - .arg("https://example.com/foo.tar.gz") + .arg("--source-sha256") + .arg("a".repeat(64)) .arg("--meta") .arg("bldimg=not-allowed") .assert() @@ -1066,8 +1057,8 @@ fn verifiable_image_must_be_digest_pinned() { .arg("--verifiable") .arg("--image") .arg("docker.io/stellar/stellar-cli:latest") - .arg("--tarball-url") - .arg("https://example.com/foo.tar.gz") + .arg("--source-sha256") + .arg("a".repeat(64)) .assert() .failure() .stderr(predicate::str::contains("bldimg format")); @@ -1090,35 +1081,66 @@ fn verifiable_image_requires_explicit_registry_host() { .arg("--verifiable") .arg("--image") .arg(short_ref) - .arg("--tarball-url") - .arg("https://example.com/foo.tar.gz") + .arg("--source-sha256") + .arg("a".repeat(64)) .assert() .failure() .stderr(predicate::str::contains("bldimg format")); } -// `--verifiable` without any source-identification flag must error. +// `--verifiable` with neither `--source-sha256` nor `--archive` must error. +// Run in a fresh (non-git) workspace so the clean-tree check is skipped and the +// missing-source error is what surfaces. #[test] -fn verifiable_requires_source_id() { +fn verifiable_requires_source_sha256() { let sandbox = TestEnv::default(); - let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let fixture_path = cargo_dir.join("tests/fixtures/workspace/contracts/add"); + let (_temp, workspace) = fresh_workspace(); sandbox .new_assert_cmd("contract") - .current_dir(fixture_path) + .current_dir(workspace.join("contracts").join("add")) .arg("build") .arg("--verifiable") .arg("--image") .arg(ZERO_DIGEST) .assert() .failure() - .stderr(predicate::str::contains("source-identification")); + .stderr(predicate::str::contains("--source-sha256")); +} + +// `--archive` generates the source archive (and computes source_sha256) before +// the docker stage, so the file is written even though the build then fails to +// reach a real image. +#[test] +fn verifiable_archive_writes_source_archive() { + 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"]); + + let archive_path = temp.path().join("src.tar.gz"); + + sandbox + .new_assert_cmd("contract") + .current_dir(workspace.join("contracts").join("add")) + .arg("build") + .arg("--verifiable") + .arg("--image") + .arg(ZERO_DIGEST) + .arg(format!("--archive={}", archive_path.display())) + .assert() + .failure(); + + assert!( + archive_path.exists(), + "source archive should be written before the docker stage" + ); } -// `--source-rev` value must match the 40-hex regex. +// `--source-sha256` value must match the 64-hex regex. #[test] -fn verifiable_source_rev_format_errors() { +fn verifiable_source_sha256_format_errors() { let sandbox = TestEnv::default(); let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); let fixture_path = cargo_dir.join("tests/fixtures/workspace/contracts/add"); @@ -1130,54 +1152,46 @@ fn verifiable_source_rev_format_errors() { .arg("--verifiable") .arg("--image") .arg(ZERO_DIGEST) - .arg("--source-repo") - .arg("https://github.com/foo/bar") - .arg("--source-rev") + .arg("--source-sha256") .arg("not-a-sha") .assert() .failure() - .stderr(predicate::str::contains("source_rev format")); + .stderr(predicate::str::contains("source_sha256 format")); } -// `--source-rev` is cross-checked against local git HEAD; a mismatch is a hard -// fail before docker is touched. +// `--source-uri` value must be a URI with a scheme. #[test] -fn verifiable_source_rev_must_match_head() { +fn verifiable_source_uri_format_errors() { 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"]); - - let bogus = "a".repeat(40); + let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/workspace/contracts/add"); sandbox .new_assert_cmd("contract") - .current_dir(workspace.join("contracts").join("add")) + .current_dir(fixture_path) .arg("build") .arg("--verifiable") .arg("--image") .arg(ZERO_DIGEST) - .arg("--source-repo") - .arg("https://github.com/foo/bar") - .arg("--source-rev") - .arg(bogus) + .arg("--source-sha256") + .arg("a".repeat(64)) + .arg("--source-uri") + .arg("not a uri") .assert() .failure() - .stderr(predicate::str::contains("does not match local HEAD")); + .stderr(predicate::str::contains("source_uri format")); } -// A dirty git tree under `--source-rev` is a hard fail (the recorded rev would -// not describe the bytes built). +// A dirty git tree is a hard fail under `--verifiable` (the recorded +// source_sha256 would not describe the bytes built). #[test] -fn verifiable_dirty_tree_errors_with_source_rev() { +fn verifiable_dirty_tree_errors() { 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"]); - let head = git_head(&workspace); - // Dirty the tree after committing so HEAD matches but status is non-empty. + // Dirty the tree after committing so status is non-empty. std::fs::write(workspace.join("dirty.txt"), b"uncommitted").unwrap(); sandbox @@ -1187,10 +1201,8 @@ fn verifiable_dirty_tree_errors_with_source_rev() { .arg("--verifiable") .arg("--image") .arg(ZERO_DIGEST) - .arg("--source-repo") - .arg("https://github.com/foo/bar") - .arg("--source-rev") - .arg(head) + .arg("--source-sha256") + .arg("a".repeat(64)) .assert() .failure() .stderr(predicate::str::contains("dirty").or(predicate::str::contains("clean tree"))); diff --git a/cmd/soroban-cli/Cargo.toml b/cmd/soroban-cli/Cargo.toml index 76d6fc0323..a1d29e654f 100644 --- a/cmd/soroban-cli/Cargo.toml +++ b/cmd/soroban-cli/Cargo.toml @@ -128,6 +128,8 @@ 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] crate-git-revision = "0.0.9" @@ -139,6 +141,5 @@ thiserror.workspace = true assert_cmd = "2.0.4" assert_fs = "1.0.7" predicates = { workspace = true } -walkdir = "2.5.0" mockito = "1.5.0" serial_test = "3.0.0" diff --git a/cmd/soroban-cli/src/commands/contract/build.rs b/cmd/soroban-cli/src/commands/contract/build.rs index 51829fa56f..9ad08ca7fd 100644 --- a/cmd/soroban-cli/src/commands/contract/build.rs +++ b/cmd/soroban-cli/src/commands/contract/build.rs @@ -99,9 +99,9 @@ pub struct Cmd { pub print_commands_only: bool, /// Build inside a trusted Docker container and record SEP-58 metadata - /// (`bldimg`, `source_rev`, `bldopt`) so the resulting WASM can be - /// reproduced and verified by third parties. Implies `--locked`. - /// Requires a clean git working tree. + /// (`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. #[arg(long, help_heading = "Verifiable")] pub verifiable: bool, @@ -111,47 +111,38 @@ pub struct Cmd { #[arg(long, requires = "verifiable", help_heading = "Verifiable")] pub image: Option, - /// SEP-58 source identification: HTTPS URL (or `github:user/repo`) of the - /// source repository. Must be passed together with `--source-rev`. - #[arg( - long, - requires = "verifiable", - requires = "source_rev", - conflicts_with_all = ["tarball_url", "tarball_sha256"], - help_heading = "Verifiable" - )] - pub source_repo: Option, - - /// SEP-58 source identification: 40-char SHA-1 of the source commit. The - /// local workspace must be a git repo at this exact SHA with a clean - /// working tree. Must be passed together with `--source-repo`. - #[arg( - long, - requires = "verifiable", - requires = "source_repo", - conflicts_with_all = ["tarball_url", "tarball_sha256"], - help_heading = "Verifiable" - )] - pub source_rev: Option, + /// SEP-58 source identification: SHA-256 of the source archive/tree + /// (recorded as the `source_sha256` meta entry). Required with + /// `--verifiable` unless `--archive` is used, which generates the archive + /// and computes this for you. + #[arg(long, requires = "verifiable", help_heading = "Verifiable")] + pub source_sha256: Option, - /// SEP-58 source identification: URL where the source tarball can be - /// downloaded. + /// 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", - conflicts_with_all = ["source_repo", "source_rev"], + requires = "source_sha256", help_heading = "Verifiable" )] - pub tarball_url: Option, - - /// SEP-58 source identification: SHA-256 of the source tarball bytes. + pub source_uri: Option, + + /// Generate a source archive for the verifiable build, then build from it + /// and record its SHA-256 as the SEP-58 `source_sha256` meta entry. Pass a + /// path to choose where the gzipped tarball is written; with no path it + /// goes to the data dir's `archives/`. In a git repo the archive is + /// `git archive HEAD`; otherwise the working directory is archived minus a + /// built-in denylist (.git, .svn, .hg, target/, node_modules/, .DS_Store). #[arg( long, + num_args = 0..=1, + require_equals = true, requires = "verifiable", - conflicts_with_all = ["source_repo", "source_rev"], help_heading = "Verifiable" )] - pub tarball_sha256: Option, + pub archive: Option>, /// Override the default docker host used by `--verifiable`. #[arg(short = 'd', long, env = "DOCKER_HOST", help_heading = "Verifiable")] @@ -288,10 +279,9 @@ impl Default for Cmd { print_commands_only: false, verifiable: false, image: None, - source_repo: None, - source_rev: None, - tarball_url: None, - tarball_sha256: None, + source_sha256: None, + source_uri: None, + archive: None, docker_host: None, build_args: BuildArgs::default(), } @@ -622,10 +612,7 @@ impl Cmd { &wasm_bytes }; - print.blankln(format!( - "Wasm File: {path} ({size_description})", - path = rel_path.display() - )); + print.blankln(format!("Wasm File: {path}", path = rel_path.display())); print.blankln(format!("Wasm Hash: {}", hex::encode(Sha256::digest(bytes)))); print.blankln(format!("Wasm Size: {size_description}")); diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index f9240fdd87..618ee15a97 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -1,4 +1,5 @@ use std::{ + io::Write, path::{Path, PathBuf}, process::Command, }; @@ -17,9 +18,12 @@ use futures_util::{StreamExt, TryStreamExt}; use regex::Regex; use semver::Version; use serde::Deserialize; +use sha2::{Digest, Sha256}; +use walkdir::WalkDir; use crate::{ commands::{container::shared::Error as ConnectionError, global}, + config::{data, locator::enforce_hardened_tree}, print::Print, }; @@ -28,7 +32,37 @@ use super::{BuiltContract, Cmd, WASM_TARGET}; const REGISTRY: &str = "docker.io/stellar/stellar-cli"; const HUB_TAGS_URL: &str = "https://hub.docker.com/v2/repositories/stellar/stellar-cli/tags/?page_size=100"; -const RESERVED_META_KEYS: &[&str] = &["bldimg", "source_rev", "bldopt"]; +const RESERVED_META_KEYS: &[&str] = &["bldimg", "source_uri", "source_sha256", "bldopt"]; + +/// Top-level names excluded when archiving a non-git working directory (we have +/// no tracked-files list to consult, so fall back to a fixed denylist of VCS +/// metadata, build/cache/transient dirs, and editor/OS/AI-assistant junk). +/// Matched against each path component, so a directory like `target/` prunes +/// its whole subtree. +const ARCHIVE_DENYLIST: &[&str] = &[ + // version control + ".git", + ".svn", + ".hg", + // build output / dependencies + "target", + "node_modules", + // transient + "log", + "logs", + "tmp", + "temp", + // OS / editor junk + ".DS_Store", + "Thumbs.db", + ".idea", + ".vscode", + // AI assistant dirs + ".claude", + ".cursor", + ".windsurf", + ".aider", +]; /// First cli release that accepts `--optimize=false` as an explicit value /// (added by commit `b17d3f0b`). Containers older than this only accept bare @@ -74,35 +108,41 @@ pub enum Error { }, #[error( - "git working tree at {path} is dirty. --source-rev requires a clean tree so the recorded source_rev matches the WASM bytes. Commit or stash your changes and try again." + "git working tree at {path} is dirty. --verifiable requires a clean tree so the recorded source_sha256 matches the WASM bytes. Commit or stash your changes and try again." )] GitDirty { path: PathBuf }, #[error( - "the cli sets bldimg, source_rev, and bldopt automatically when --verifiable is used; remove them from --meta. Got reserved key: {key}" + "the cli sets bldimg, source_uri, source_sha256, and bldopt automatically when --verifiable is used; remove them from --meta. Got reserved key: {key}" )] ReservedMetaKey { key: String }, - #[error("--verifiable requires a SEP-58 source-identification combination. Pass one of: (--source-repo + --source-rev), (--tarball-url and/or --tarball-sha256).")] - MissingSourceId, + #[error("--verifiable requires --source-sha256 (the SEP-58 source_sha256: 64-char hex SHA-256 of the source), or --archive to generate the source archive and compute it. --source-uri is optional.")] + MissingSourceSha256, - #[error("--source-rev value {value:?} does not match the SEP-58 source_rev format `^[0-9a-f]{{40}}$` (full 40-char SHA-1 of the source commit).")] - SourceRevFormat { value: String }, + #[error("--source-sha256 value {value:?} does not match the SEP-58 source_sha256 format `^[0-9a-f]{{64}}$` (64-char lower-case hex).")] + SourceSha256Format { value: String }, - #[error("--source-repo value {value:?} does not match the SEP-58 source_repo format `^(https?://\\S+|github:[^/\\s]+/[^/\\s]+)$`.")] - SourceRepoFormat { value: String }, + #[error("--source-uri value {value:?} does not match the SEP-58 source_uri format `^[a-zA-Z][a-zA-Z0-9+.-]*:\\S+$` (a URI with a scheme, e.g. https://example.com/src.tar.gz).")] + SourceUriFormat { value: String }, - #[error("--tarball-url value {value:?} does not match the SEP-58 tarball_url format `^https?://\\S+$`.")] - TarballUrlFormat { value: String }, + #[error("--source-sha256 {provided} does not match the SHA-256 of the generated archive {computed}. Omit --source-sha256 to record the computed value, or fix the value.")] + SourceSha256Mismatch { provided: String, computed: String }, - #[error("--tarball-sha256 value {value:?} does not match the SEP-58 tarball_sha256 format `^[0-9a-f]{{64}}$`.")] - TarballSha256Format { value: String }, + #[error("`git archive` failed in {path}: {stderr}")] + GitArchive { path: PathBuf, stderr: String }, - #[error("--source-rev requires a git workspace at {path}; `git rev-parse HEAD` failed there.")] - SourceRevNotGitRepo { path: PathBuf }, + #[error("could not write source archive to {path}: {source}")] + ArchiveWrite { + path: PathBuf, + source: std::io::Error, + }, - #[error("--source-rev {claimed} does not match local HEAD {head}. Commit, switch, or pass the correct rev.")] - SourceRevHeadMismatch { claimed: String, head: String }, + #[error("could not extract source archive: {0}")] + ArchiveExtract(std::io::Error), + + #[error(transparent)] + Data(#[from] data::Error), #[error("container build exited with status {status}. To reproduce manually:\n docker run --rm -v {mount}:/source {image} {args}")] ContainerExit { @@ -130,22 +170,58 @@ pub async fn run( } } - // Stage 2: local filesystem + git, no network. Resolve the workspace root - // first so the (optional) `--source-rev` git cross-check has a path to - // anchor on. + // Stage 2: local filesystem + git, no network. let workspace_root = resolve_workspace_root(cmd)?; - let source_ids = validate_source_ids(cmd, &workspace_root)?; - - // Pick the anchor the container bind-mounts and the `--manifest-path` - // bldopt is relativized against. A verifier will clone source_repo (or - // extract the tarball) into a fresh tempdir and bind-mount its root, so - // the build must do the symmetric thing on the host: bind-mount the local - // clone root (where `.git` lives) or, if there's no clone, the user's - // cwd. We do NOT validate that the local clone matches `--source-repo` — - // a wrong clone produces different bytes, and verify catches that at - // byte-comparison time. + validate_source_formats(cmd)?; + + // Pick the anchor for the local source: the `--manifest-path` bldopt is + // relativized against it, and (when `--archive` is not used) it's also what + // gets bind-mounted into the container. We do NOT validate that it matches + // source_uri — a wrong source produces different bytes, and verify catches + // that at byte-comparison time. let source_root = resolve_source_root(cmd); + // A dirty working tree would make the recorded source_sha256 fail to + // describe the bytes actually built, so refuse to proceed. Skipped when + // the source root isn't a git repo (we can't check, e.g. archive sources). + enforce_clean_tree(&source_root)?; + + // Resolve the recorded source_sha256 and the directory the container mounts + // at /source. With `--archive`, the CLI builds the source archive, records + // its hash, and builds from the *extracted* archive (in a hardened tempdir) + // so the WASM is produced from exactly the bytes that were hashed. Without + // it, the user supplies --source-sha256 and we mount the working tree. + let resolved = match &cmd.archive { + Some(_) => { + let a = resolve_archive(cmd, &source_root, print)?; + // The extracted `source/` dir mirrors `source_root` exactly and is + // both the container mount and the tree the build writes `target/` + // into, so it's what `collect_built_contracts` resolves artifacts + // against. + let mount_root = a.extracted_root.join("source"); + ResolvedSource { + source_sha256: a.source_sha256, + extracted_root: Some(mount_root.clone()), + mount_root, + _tmp: Some(a.tmp), + } + } + None => ResolvedSource { + source_sha256: cmd + .source_sha256 + .clone() + .ok_or(Error::MissingSourceSha256)?, + mount_root: source_root.clone(), + extracted_root: None, + _tmp: None, + }, + }; + + let source_ids = SourceIds { + source_uri: cmd.source_uri.clone(), + source_sha256: Some(resolved.source_sha256.clone()), + }; + // Defer the info banner until every validation has passed, so it doesn't // appear right before an error. if !cmd.locked { @@ -171,8 +247,20 @@ pub async fn run( probe_supports_optimize_false_syntax(&image_ref, &docker, print).await }; - let (forwarded_args, bldopts) = - build_forwarded_args(cmd, &source_root, supports_explicit_optimize_false); + let package = resolve_build_package(cmd)?; + if cmd.package.is_none() { + if let Some(pkg) = &package { + print.infoln(format!( + "Inferred --package={pkg} and using it as a build option." + )); + } + } + let (forwarded_args, bldopts) = build_forwarded_args( + cmd, + &source_root, + package.as_deref(), + supports_explicit_optimize_false, + ); let metadata_args = build_metadata_args(&image_ref, &source_ids, &bldopts); let container_cmd_args = compose_container_args(&forwarded_args, &metadata_args); @@ -182,7 +270,7 @@ pub async fn run( // `--verbose` because verifications are run as part of pipelines. run_in_container( &image_ref, - &source_root, + &resolved.mount_root, &container_cmd_args, &docker, print, @@ -191,7 +279,18 @@ pub async fn run( .await?; let _ = global_args; - collect_built_contracts(cmd, &workspace_root, print) + let _ = workspace_root; + collect_built_contracts(cmd, &source_root, resolved.extracted_root.as_deref(), print) +} + +/// The recorded `source_sha256`, the directory bind-mounted at `/source`, and +/// (when `--archive` is used) the extracted-archive root plus its tempdir guard +/// — held so the temp dir outlives the container build and artifact collection. +struct ResolvedSource { + source_sha256: String, + mount_root: PathBuf, + extracted_root: Option, + _tmp: Option, } fn resolve_workspace_root(cmd: &Cmd) -> Result { @@ -209,7 +308,7 @@ fn resolve_workspace_root(cmd: &Cmd) -> Result { /// `--manifest-path` (or cwd, if no manifest_path) looking for a `.git` /// directory; return its parent. If none is found, fall back to cwd. /// -/// This isn't a validation step — any `.git` will do. Wrong-clone mistakes +/// This isn't a validation step — any `.git` will do. Wrong-source mistakes /// are caught later by the verify-side byte comparison. fn resolve_source_root(cmd: &Cmd) -> PathBuf { let start = if let Some(p) = &cmd.manifest_path { @@ -232,105 +331,258 @@ fn resolve_source_root(cmd: &Cmd) -> PathBuf { std::env::current_dir().unwrap_or(start) } -/// Source-identification fields, gathered from the corresponding CLI flags -/// after validation. Each is `Some` only when the user passed the flag and the -/// value matched the SEP-58 format regex. The four fields cannot all be -/// `None` — `validate_source_ids` rejects that case. +/// Source-identification fields recorded as SEP-58 meta. `source_sha256` is +/// always `Some` by the time these are built in `run()` (resolved from +/// `--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_repo: Option, - source_rev: Option, - tarball_url: Option, - tarball_sha256: Option, + source_uri: Option, + source_sha256: Option, } -fn validate_source_ids(cmd: &Cmd, workspace_root: &Path) -> Result { - let ids = SourceIds { - source_repo: cmd.source_repo.clone(), - source_rev: cmd.source_rev.clone(), - tarball_url: cmd.tarball_url.clone(), - tarball_sha256: cmd.tarball_sha256.clone(), - }; - - if ids.source_repo.is_none() - && ids.source_rev.is_none() - && ids.tarball_url.is_none() - && ids.tarball_sha256.is_none() - { - return Err(Error::MissingSourceId); - } - - if let Some(v) = &ids.source_rev { - if !source_rev_regex().is_match(v) { - return Err(Error::SourceRevFormat { value: v.clone() }); +/// Format-validate the user-supplied source flags. Requiredness is enforced in +/// `run()` (it depends on whether `--archive` is used), not here. +fn validate_source_formats(cmd: &Cmd) -> Result<(), Error> { + if let Some(sha) = &cmd.source_sha256 { + if !source_sha256_regex().is_match(sha) { + return Err(Error::SourceSha256Format { value: sha.clone() }); } } - - if let Some(v) = &ids.source_repo { - if !source_repo_regex().is_match(v) { - return Err(Error::SourceRepoFormat { value: v.clone() }); + if let Some(uri) = &cmd.source_uri { + if !source_uri_regex().is_match(uri) { + return Err(Error::SourceUriFormat { value: uri.clone() }); } } + Ok(()) +} - if let Some(v) = &ids.tarball_url { - if !tarball_url_regex().is_match(v) { - return Err(Error::TarballUrlFormat { value: v.clone() }); - } - } +/// Outcome of `--archive`: the generated archive's SHA-256 and the directory it +/// was extracted into (held alive by `tmp`). +struct ArchiveResult { + source_sha256: String, + extracted_root: PathBuf, + tmp: tempfile::TempDir, +} - if let Some(v) = &ids.tarball_sha256 { - if !tarball_sha256_regex().is_match(v) { - return Err(Error::TarballSha256Format { value: v.clone() }); +/// Build the source archive, record its hash, write it out, and extract it into +/// a permission-hardened tempdir that the container then builds from. +fn resolve_archive(cmd: &Cmd, source_root: &Path, print: &Print) -> Result { + let bytes = build_source_archive(source_root, print)?; + let computed = hex::encode(Sha256::digest(&bytes)); + + // If the user pinned a hash, it must match what we produced. + if let Some(provided) = &cmd.source_sha256 { + if provided != &computed { + return Err(Error::SourceSha256Mismatch { + provided: provided.clone(), + computed, + }); } } - if let Some(claimed) = &ids.source_rev { - cross_check_source_rev_against_git(workspace_root, claimed)?; + // `Some(Some(path))` → write there; `Some(None)` → content-addressed name + // under the managed archives dir. + let out_path = match &cmd.archive { + Some(Some(p)) => p.clone(), + Some(None) => data::archives_dir()?.join(format!("{computed}.tar.gz")), + None => unreachable!("resolve_archive is only called when --archive is set"), + }; + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent).map_err(|source| Error::ArchiveWrite { + path: out_path.clone(), + source, + })?; } + std::fs::write(&out_path, &bytes).map_err(|source| Error::ArchiveWrite { + path: out_path.clone(), + source, + })?; + print.infoln(format!( + "Wrote source archive {} (source_sha256 {computed})", + out_path.display() + )); - Ok(ids) + // Extract and harden, then build from the extracted copy so the WASM is + // produced from exactly the archived bytes. + // + // Extract under the data dir, 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. + let base = data::data_local_dir()?; + std::fs::create_dir_all(&base).map_err(|source| Error::ArchiveWrite { + path: base.clone(), + source, + })?; + let tmp = tempfile::Builder::new() + .prefix("verifiable-src-") + .tempdir_in(&base) + .map_err(Error::ArchiveExtract)?; + unpack_targz(&bytes, tmp.path())?; + enforce_hardened_tree(tmp.path()).map_err(Error::ArchiveExtract)?; + + let extracted_root = tmp.path().to_path_buf(); + Ok(ArchiveResult { + source_sha256: computed, + extracted_root, + tmp, + }) } -fn cross_check_source_rev_against_git(workspace_root: &Path, claimed: &str) -> Result<(), Error> { - let rev_out = Command::new("git") +/// Whether `source_root` is inside a git work tree. +fn is_git_repo(source_root: &Path) -> bool { + Command::new("git") .arg("-C") - .arg(workspace_root) + .arg(source_root) .arg("rev-parse") + .arg("--is-inside-work-tree") + .output() + .is_ok_and(|o| o.status.success()) +} + +/// Produce the gzipped source tarball bytes. Entries are rooted under a +/// top-level `source/` prefix (so the archive extracts to a `source/` dir, +/// mirroring the container's `/source` mount). In a git repo this is `git +/// archive HEAD` (the committed tree); otherwise the working directory is +/// walked and tarred, skipping `ARCHIVE_DENYLIST` entries, after warning. +fn build_source_archive(source_root: &Path, print: &Print) -> Result, Error> { + let tar = if is_git_repo(source_root) { + git_archive_tar(source_root)? + } else { + print.warnln(format!( + "{} is not a git repository; archiving the working directory. Inspect the generated archive to confirm its contents.", + source_root.display(), + )); + walk_tar(source_root)? + }; + gzip(&tar) +} + +/// `git archive --format=tar --prefix=source/ HEAD`, returning the tar bytes. +fn git_archive_tar(source_root: &Path) -> Result, Error> { + let out = Command::new("git") + .arg("-C") + .arg(source_root) + .arg("archive") + .arg("--format=tar") + .arg("--prefix=source/") .arg("HEAD") .output() - .map_err(|e| Error::GitInvoke { - path: workspace_root.to_path_buf(), - source: e, + .map_err(|source| Error::GitInvoke { + path: source_root.to_path_buf(), + source, })?; - - if !rev_out.status.success() { - return Err(Error::SourceRevNotGitRepo { - path: workspace_root.to_path_buf(), + if !out.status.success() { + return Err(Error::GitArchive { + path: source_root.to_path_buf(), + stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(), }); } + Ok(out.stdout) +} - let head = String::from_utf8_lossy(&rev_out.stdout).trim().to_string(); - if head != claimed { - return Err(Error::SourceRevHeadMismatch { - claimed: claimed.to_string(), - head, - }); +/// Tar the working tree under `source_root`, skipping denylisted path +/// components, with entries sorted and headers normalized (deterministic mode) +/// so the bytes are reproducible. Each entry is prefixed with `source/`. +fn walk_tar(source_root: &Path) -> Result, Error> { + let mut files: Vec = Vec::new(); + let walk = WalkDir::new(source_root) + .sort_by_file_name() + .into_iter() + .filter_entry(|e| !is_denylisted(e.file_name())); + for entry in walk { + let entry = entry.map_err(|e| Error::ArchiveWrite { + path: source_root.to_path_buf(), + source: e.into(), + })?; + if entry.file_type().is_file() { + files.push(entry.path().to_path_buf()); + } } + files.sort(); + let mut builder = tar::Builder::new(Vec::new()); + builder.mode(tar::HeaderMode::Deterministic); + for path in &files { + let rel = path.strip_prefix(source_root).unwrap_or(path); + let name = Path::new("source").join(rel); + let mut f = std::fs::File::open(path).map_err(|source| Error::ArchiveWrite { + path: path.clone(), + source, + })?; + builder + .append_file(&name, &mut f) + .map_err(|source| Error::ArchiveWrite { + path: path.clone(), + source, + })?; + } + builder.into_inner().map_err(|source| Error::ArchiveWrite { + path: source_root.to_path_buf(), + source, + }) +} + +/// A path component is denylisted if it equals a denylist entry, or — for +/// dotted entries, which double as extension filters (e.g. `.swp`, `.log`) — if +/// it ends with that entry. Plain names (`target`, `node_modules`) match +/// exactly only, so `mytarget` is not excluded. +fn is_denylisted(name: &std::ffi::OsStr) -> bool { + let name = name.to_string_lossy(); + ARCHIVE_DENYLIST + .iter() + .any(|d| name == *d || (d.starts_with('.') && name.ends_with(d))) +} + +/// Gzip with a default (mtime-zeroed) header so the same tar bytes always hash +/// the same. +fn gzip(bytes: &[u8]) -> Result, Error> { + let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + enc.write_all(bytes).map_err(|source| Error::ArchiveWrite { + path: PathBuf::new(), + source, + })?; + enc.finish().map_err(|source| Error::ArchiveWrite { + path: PathBuf::new(), + source, + }) +} + +/// Decompress gzip and unpack the tar into `dest`. Entries are `source/…`, so +/// they land at `/source/…`. +fn unpack_targz(bytes: &[u8], dest: &Path) -> Result<(), Error> { + let dec = flate2::read::GzDecoder::new(bytes); + tar::Archive::new(dec) + .unpack(dest) + .map_err(Error::ArchiveExtract) +} + +/// Refuse to run a verifiable build against a dirty git working tree: the +/// bind-mounted source must match the recorded source_sha256 for the build to +/// be reproducible. When the source root isn't a git repo (e.g. an extracted +/// archive) we can't check, so we proceed — the user owns the source_sha256 +/// they pass, and verify catches a mismatch at byte-comparison time. +fn enforce_clean_tree(source_root: &Path) -> Result<(), Error> { let status = Command::new("git") .arg("-C") - .arg(workspace_root) + .arg(source_root) .arg("status") .arg("--porcelain") .output() .map_err(|e| Error::GitInvoke { - path: workspace_root.to_path_buf(), + path: source_root.to_path_buf(), source: e, })?; + // Not a git repo (or git refused): can't verify cleanliness, proceed. + if !status.status.success() { + return Ok(()); + } + if !status.stdout.is_empty() { return Err(Error::GitDirty { - path: workspace_root.to_path_buf(), + path: source_root.to_path_buf(), }); } @@ -342,20 +594,45 @@ fn bldimg_regex() -> Regex { .unwrap() } -fn source_rev_regex() -> Regex { - Regex::new(r"^[0-9a-f]{40}$").unwrap() -} - -fn source_repo_regex() -> Regex { - Regex::new(r"^(https?://\S+|github:[^/\s]+/[^/\s]+)$").unwrap() +fn source_sha256_regex() -> Regex { + Regex::new(r"^[0-9a-f]{64}$").unwrap() } -fn tarball_url_regex() -> Regex { - Regex::new(r"^https?://\S+$").unwrap() +fn source_uri_regex() -> Regex { + Regex::new(r"^[a-zA-Z][a-zA-Z0-9+.-]*:\S+$").unwrap() } -fn tarball_sha256_regex() -> Regex { - Regex::new(r"^[0-9a-f]{64}$").unwrap() +/// Resolve the package to pin as `--package`. An explicit `--package` wins. +/// Otherwise, when the workspace builds exactly one cdylib by default, return +/// its name so the recorded bldopt is reproducible even if the workspace's +/// default members change later. Returns `None` when the selection is +/// ambiguous (zero or multiple default cdylibs) — the build then keeps cargo's +/// default behavior of building them all, which `--package` can't express +/// (the container's flag is singular). +fn resolve_build_package(cmd: &Cmd) -> Result, Error> { + if cmd.package.is_some() { + return Ok(cmd.package.clone()); + } + let mut mc = MetadataCommand::new(); + mc.no_deps(); + if let Some(p) = &cmd.manifest_path { + mc.manifest_path(p); + } + let md = mc.exec().map_err(Error::Metadata)?; + let mut names: Vec = md + .packages + .iter() + .filter(|p| md.workspace_default_members.contains(&p.id)) + .filter(|p| { + p.targets + .iter() + .any(|t| t.crate_types.iter().any(|c| c == "cdylib")) + }) + .map(|p| p.name.clone()) + .collect(); + names.sort(); + names.dedup(); + Ok((names.len() == 1).then(|| names.remove(0))) } /// The flags forwarded to the container's `stellar contract build`, plus the @@ -371,6 +648,7 @@ fn tarball_sha256_regex() -> Regex { fn build_forwarded_args( cmd: &Cmd, workspace_root: &Path, + package: Option<&str>, supports_explicit_optimize_false: bool, ) -> (Vec, Vec) { let mut forwarded: Vec = Vec::new(); @@ -403,7 +681,10 @@ fn build_forwarded_args( if cmd.no_default_features { record("--no-default-features".to_string()); } - if let Some(pkg) = &cmd.package { + // Always pin the package when it can be resolved (explicit `--package`, or + // a workspace that builds exactly one cdylib by default) so the recorded + // bldopt stays reproducible even if workspace default members change later. + if let Some(pkg) = package { record(format!("--package={pkg}")); } for (k, v) in &cmd.build_args.meta { @@ -435,17 +716,11 @@ fn build_metadata_args(image_ref: &str, ids: &SourceIds, bldopts: &[String]) -> push(&mut out, "bldimg", image_ref); - if let Some(v) = &ids.source_repo { - push(&mut out, "source_repo", v); - } - if let Some(v) = &ids.source_rev { - push(&mut out, "source_rev", v); + if let Some(v) = &ids.source_uri { + push(&mut out, "source_uri", v); } - if let Some(v) = &ids.tarball_url { - push(&mut out, "tarball_url", v); - } - if let Some(v) = &ids.tarball_sha256 { - push(&mut out, "tarball_sha256", v); + if let Some(v) = &ids.source_sha256 { + push(&mut out, "source_sha256", v); } for o in bldopts { @@ -797,9 +1072,16 @@ async fn run_in_container( Ok(()) } +/// Collect the built WASM artifacts. Package names and the host target dir come +/// from host `cargo metadata`. `extracted_root` is set when the build ran +/// against an extracted archive (step: `--archive`): the artifacts then live +/// under that tree's target dir and must be copied out before its tempdir +/// drops. `source_root` is the host source root the extracted tree mirrors, so +/// the target dir's position relative to it carries over. fn collect_built_contracts( cmd: &Cmd, - workspace_root: &Path, + source_root: &Path, + extracted_root: Option<&Path>, _print: &Print, ) -> Result, super::Error> { let mut mc = MetadataCommand::new(); @@ -808,7 +1090,15 @@ fn collect_built_contracts( mc.manifest_path(p); } let md = mc.exec().map_err(Error::Metadata)?; - let target_dir = md.target_directory.as_std_path(); + let host_target = md.target_directory.as_std_path(); + + // Where the build actually wrote artifacts. For an extracted-archive build + // that's `/`; otherwise the + // host target dir (the working tree was bind-mounted directly). + let src_target = match extracted_root { + Some(er) => er.join(host_target.strip_prefix(source_root).unwrap_or(host_target)), + None => host_target.to_path_buf(), + }; let mut out = Vec::new(); for p in &md.packages { @@ -827,28 +1117,39 @@ fn collect_built_contracts( continue; } let wasm_name = p.name.replace('-', "_"); - let path = Path::new(target_dir) - .join(WASM_TARGET) + let rel = Path::new(WASM_TARGET) .join(&cmd.profile) .join(format!("{wasm_name}.wasm")); - if let Some(out_dir) = &cmd.out_dir { - let dest = out_dir.join(format!("{wasm_name}.wasm")); - if path.exists() { - std::fs::create_dir_all(out_dir).map_err(super::Error::CreatingOutDir)?; - std::fs::copy(&path, &dest).map_err(super::Error::CopyingWasmFile)?; - out.push(BuiltContract { - name: p.name.clone(), - path: dest, - }); - continue; + let src = src_target.join(&rel); + + // Destination: --out-dir wins; else if the build ran in a tempdir, copy + // into the host target dir so the artifact survives; else leave in + // place (the working tree was mounted, so it's already on the host). + let dest = if let Some(out_dir) = &cmd.out_dir { + Some(out_dir.join(format!("{wasm_name}.wasm"))) + } else if extracted_root.is_some() { + Some(host_target.join(&rel)) + } else { + None + }; + + let path = match dest { + Some(dest) if src.exists() => { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).map_err(super::Error::CreatingOutDir)?; + } + std::fs::copy(&src, &dest).map_err(super::Error::CopyingWasmFile)?; + dest } - } + // Source missing: report the intended dest (matches prior leniency). + Some(dest) => dest, + None => src, + }; out.push(BuiltContract { name: p.name.clone(), path, }); } - let _ = workspace_root; Ok(out) } @@ -863,7 +1164,7 @@ mod tests { #[test] fn build_forwarded_args_defaults() { let cmd = Cmd::default(); - let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), true); + let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), true); // Default optimize=true → bare `--optimize` recorded + forwarded. assert_eq!( forwarded, @@ -882,7 +1183,7 @@ mod tests { package: Some("contract-a".to_string()), ..Cmd::default() }; - let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), true); + let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), true); assert!(forwarded.contains(&"--features=a,b".to_string())); assert!(forwarded.contains(&"--package=contract-a".to_string())); assert!(bldopts.contains(&"--features=a,b".to_string())); @@ -890,6 +1191,25 @@ mod tests { assert!(bldopts.contains(&"--locked".to_string())); } + #[test] + fn build_forwarded_args_records_resolved_package_when_unspecified() { + // No `--package` on the cmd, but the caller resolved one (single + // default cdylib); it must still be forwarded and recorded. + let cmd = Cmd::default(); + assert!(cmd.package.is_none()); + let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), Some("hello-world"), true); + assert!(forwarded.contains(&"--package=hello-world".to_string())); + assert!(bldopts.contains(&"--package=hello-world".to_string())); + } + + #[test] + fn build_forwarded_args_omits_package_when_unresolved() { + let cmd = Cmd::default(); + let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), None, true); + assert!(!forwarded.iter().any(|a| a.starts_with("--package"))); + assert!(!bldopts.iter().any(|a| a.starts_with("--package"))); + } + #[test] fn build_forwarded_args_records_meta_and_manifest() { let cmd = Cmd { @@ -903,7 +1223,7 @@ mod tests { }, ..Cmd::default() }; - let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), true); + let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), true); assert!(forwarded.contains(&"--meta=home_domain=fnando.com".to_string())); assert!(forwarded.contains(&"--meta=author=alice".to_string())); assert!(forwarded.contains(&"--manifest-path=contracts/add/Cargo.toml".to_string())); @@ -921,7 +1241,7 @@ mod tests { }, ..Cmd::default() }; - let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), true); + let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), true); assert!(forwarded.contains(&"--optimize=false".to_string())); assert!(bldopts.contains(&"--optimize=false".to_string())); } @@ -935,7 +1255,7 @@ mod tests { }, ..Cmd::default() }; - let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), false); + let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), false); // Old container's default is already false; record nothing. // Passing `--optimize=false` to a pre-26.1.0 cli would fail. assert!(!forwarded.iter().any(|a| a.starts_with("--optimize"))); @@ -949,12 +1269,10 @@ mod tests { } #[test] - fn build_metadata_args_source_repo_and_rev() { + fn build_metadata_args_uri_and_sha256() { let ids = SourceIds { - source_repo: Some("https://github.com/foo/bar".to_string()), - source_rev: Some("a".repeat(40)), - tarball_url: None, - tarball_sha256: None, + source_uri: Some("https://example.com/src.tar.gz".to_string()), + source_sha256: Some("a".repeat(64)), }; let m = build_metadata_args( "docker.io/stellar/stellar-cli@sha256:abc", @@ -962,126 +1280,171 @@ mod tests { &["--locked".to_string(), "--features=a".to_string()], ); let p = pairs(&m); - // bldimg first; source-ids only for what's set; bldopts last. + // bldimg first; source_uri then source_sha256; bldopts last. assert_eq!( p[0], ("--meta", "bldimg=docker.io/stellar/stellar-cli@sha256:abc") ); - assert_eq!(p[1], ("--meta", "source_repo=https://github.com/foo/bar")); + assert_eq!( + p[1], + ("--meta", "source_uri=https://example.com/src.tar.gz") + ); assert_eq!(p[2].0, "--meta"); - assert!(p[2].1.starts_with("source_rev=")); + assert!(p[2].1.starts_with("source_sha256=")); assert_eq!(p[3], ("--meta", "bldopt=--locked")); assert_eq!(p[4], ("--meta", "bldopt=--features=a")); - // No tarball entries emitted when those fields are None. - assert!(!m.iter().any(|s| s.starts_with("tarball_"))); - } - - #[test] - fn build_metadata_args_tarball_url_only() { - let ids = SourceIds { - tarball_url: Some("https://example.com/foo.tar.gz".to_string()), - ..SourceIds::default() - }; - let m = build_metadata_args("docker.io/stellar/stellar-cli@sha256:abc", &ids, &[]); - assert!(m - .iter() - .any(|s| s == "tarball_url=https://example.com/foo.tar.gz")); - assert!(!m.iter().any(|s| s.starts_with("source_"))); - assert!(!m.iter().any(|s| s.starts_with("tarball_sha256="))); } #[test] - fn build_metadata_args_tarball_pair() { + fn build_metadata_args_sha256_only_omits_uri() { let ids = SourceIds { - tarball_url: Some("https://example.com/foo.tar.gz".to_string()), - tarball_sha256: Some("f".repeat(64)), + source_sha256: Some("f".repeat(64)), ..SourceIds::default() }; let m = build_metadata_args("docker.io/stellar/stellar-cli@sha256:abc", &ids, &[]); assert!(m .iter() - .any(|s| s == "tarball_url=https://example.com/foo.tar.gz")); - assert!(m - .iter() - .any(|s| s == &format!("tarball_sha256={}", "f".repeat(64)))); + .any(|s| s == &format!("source_sha256={}", "f".repeat(64)))); + assert!(!m.iter().any(|s| s.starts_with("source_uri="))); } #[test] - fn validate_source_ids_missing_all_errors() { - let cmd = Cmd::default(); - let err = validate_source_ids(&cmd, ws()).unwrap_err(); - assert!(matches!(err, Error::MissingSourceId)); - } - - #[test] - fn validate_source_ids_rejects_bad_source_rev_format() { + fn validate_source_formats_rejects_bad_sha256() { let cmd = Cmd { - source_repo: Some("https://github.com/foo/bar".to_string()), - source_rev: Some("not-a-sha".to_string()), + source_sha256: Some("not-a-sha".to_string()), ..Cmd::default() }; - let err = validate_source_ids(&cmd, ws()).unwrap_err(); - assert!(matches!(err, Error::SourceRevFormat { .. })); + let err = validate_source_formats(&cmd).unwrap_err(); + assert!(matches!(err, Error::SourceSha256Format { .. })); } #[test] - fn validate_source_ids_rejects_bad_source_repo_format() { + fn validate_source_formats_rejects_bad_uri() { let cmd = Cmd { - source_repo: Some("foo/bar".to_string()), // missing scheme - source_rev: Some("a".repeat(40)), + source_uri: Some("not a uri".to_string()), // no scheme + source_sha256: Some("a".repeat(64)), ..Cmd::default() }; - let err = validate_source_ids(&cmd, ws()).unwrap_err(); - assert!(matches!(err, Error::SourceRepoFormat { .. })); + let err = validate_source_formats(&cmd).unwrap_err(); + assert!(matches!(err, Error::SourceUriFormat { .. })); } #[test] - fn validate_source_ids_rejects_bad_tarball_url() { + fn validate_source_formats_accepts_valid_and_absent() { + // Both absent is fine here — requiredness is enforced in run(). + validate_source_formats(&Cmd::default()).unwrap(); let cmd = Cmd { - tarball_url: Some("ftp://example.com/foo.tar.gz".to_string()), + source_uri: Some("https://example.com/src.tar.gz".to_string()), + source_sha256: Some("f".repeat(64)), ..Cmd::default() }; - let err = validate_source_ids(&cmd, ws()).unwrap_err(); - assert!(matches!(err, Error::TarballUrlFormat { .. })); + validate_source_formats(&cmd).unwrap(); } #[test] - fn validate_source_ids_rejects_short_tarball_sha256() { - let cmd = Cmd { - tarball_sha256: Some("abc".to_string()), - ..Cmd::default() - }; - let err = validate_source_ids(&cmd, ws()).unwrap_err(); - assert!(matches!(err, Error::TarballSha256Format { .. })); + fn is_denylisted_matches_names_and_dotted_suffixes() { + use std::ffi::OsStr; + // exact name matches + assert!(is_denylisted(OsStr::new("target"))); + assert!(is_denylisted(OsStr::new(".git"))); + assert!(is_denylisted(OsStr::new(".DS_Store"))); + // plain names match exactly only + assert!(!is_denylisted(OsStr::new("mytarget"))); + assert!(!is_denylisted(OsStr::new("targets"))); + // dotted entries also match as suffix (extension-style) + assert!(is_denylisted(OsStr::new("backup.git"))); + // unrelated files pass through + assert!(!is_denylisted(OsStr::new("Cargo.toml"))); + assert!(!is_denylisted(OsStr::new("lib.rs"))); + } + + // Initialize a git repo at `root` with one commit of everything present. + #[cfg(unix)] + fn git_init_commit(root: &Path) { + for args in [ + &["init", "-q", "-b", "main"][..], + &["add", "-A"][..], + &["commit", "-q", "-m", "init"][..], + ] { + let ok = Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .env("GIT_AUTHOR_NAME", "T") + .env("GIT_AUTHOR_EMAIL", "t@e.x") + .env("GIT_COMMITTER_NAME", "T") + .env("GIT_COMMITTER_EMAIL", "t@e.x") + .status() + .unwrap() + .success(); + assert!(ok); + } } #[test] - fn validate_source_ids_accepts_tarball_url_alone() { - let cmd = Cmd { - tarball_url: Some("https://example.com/foo.tar.gz".to_string()), - ..Cmd::default() - }; - let ids = validate_source_ids(&cmd, ws()).unwrap(); - assert_eq!( - ids.tarball_url.as_deref(), - Some("https://example.com/foo.tar.gz") - ); - assert!(ids.source_repo.is_none()); - assert!(ids.source_rev.is_none()); - assert!(ids.tarball_sha256.is_none()); + #[cfg(unix)] + fn build_source_archive_git_is_prefixed_and_deterministic() { + use std::os::unix::fs::PermissionsExt; + let print = Print::new(true); + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + std::fs::write(root.join("Cargo.toml"), b"# crate").unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("src/lib.rs"), b"// code").unwrap(); + git_init_commit(root); + + let a = build_source_archive(root, &print).unwrap(); + let b = build_source_archive(root, &print).unwrap(); + assert!(!a.is_empty()); + assert_eq!(a, b, "same commit should produce identical bytes"); + + let sha = hex::encode(Sha256::digest(&a)); + assert_eq!(sha.len(), 64); + + // Unpack and confirm the `source/` prefix + hardened perms. + let dest = tempfile::TempDir::new().unwrap(); + unpack_targz(&a, dest.path()).unwrap(); + assert!(dest.path().join("source/Cargo.toml").exists()); + assert!(dest.path().join("source/src/lib.rs").exists()); + + enforce_hardened_tree(dest.path()).unwrap(); + let file_mode = std::fs::metadata(dest.path().join("source/Cargo.toml")) + .unwrap() + .permissions() + .mode() + & 0o777; + let dir_mode = std::fs::metadata(dest.path().join("source")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(file_mode, 0o600); + assert_eq!(dir_mode, 0o700); } #[test] - fn validate_source_ids_accepts_tarball_sha256_alone() { - let cmd = Cmd { - tarball_sha256: Some("f".repeat(64)), - ..Cmd::default() - }; - let ids = validate_source_ids(&cmd, ws()).unwrap(); - assert_eq!( - ids.tarball_sha256.as_deref(), - Some("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") - ); + fn build_source_archive_non_git_excludes_denylist() { + let print = Print::new(true); + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + std::fs::write(root.join("Cargo.toml"), b"# crate").unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("src/lib.rs"), b"// code").unwrap(); + // Planted dirs that must be excluded. + std::fs::create_dir_all(root.join("target/debug")).unwrap(); + std::fs::write(root.join("target/debug/x"), b"junk").unwrap(); + std::fs::create_dir_all(root.join(".git")).unwrap(); + std::fs::write(root.join(".git/config"), b"junk").unwrap(); + + let bytes = build_source_archive(root, &print).unwrap(); + let dest = tempfile::TempDir::new().unwrap(); + unpack_targz(&bytes, dest.path()).unwrap(); + + assert!(dest.path().join("source/Cargo.toml").exists()); + assert!(dest.path().join("source/src/lib.rs").exists()); + assert!(!dest.path().join("source/target").exists()); + assert!(!dest.path().join("source/.git").exists()); + assert_eq!(hex::encode(Sha256::digest(&bytes)).len(), 64); } #[test] @@ -1114,33 +1477,20 @@ mod tests { } #[test] - fn source_rev_regex_matches_40_hex() { - assert!(source_rev_regex().is_match(&"a".repeat(40))); - assert!(!source_rev_regex().is_match(&"a".repeat(39))); - assert!(!source_rev_regex().is_match(&"A".repeat(40))); // upper-case rejected - } - - #[test] - fn source_repo_regex_accepts_https_and_github_shorthand() { - assert!(source_repo_regex().is_match("https://github.com/foo/bar")); - assert!(source_repo_regex().is_match("http://example.com/foo.git")); - assert!(source_repo_regex().is_match("github:foo/bar")); - assert!(!source_repo_regex().is_match("foo/bar")); - assert!(!source_repo_regex().is_match("git@github.com:foo/bar.git")); - } - - #[test] - fn tarball_url_regex_accepts_http_only() { - assert!(tarball_url_regex().is_match("https://example.com/foo.tar.gz")); - assert!(tarball_url_regex().is_match("http://example.com/foo.tar.gz")); - assert!(!tarball_url_regex().is_match("ftp://example.com/foo.tar.gz")); + fn source_sha256_regex_matches_64_hex() { + assert!(source_sha256_regex().is_match(&"f".repeat(64))); + assert!(!source_sha256_regex().is_match(&"f".repeat(63))); + assert!(!source_sha256_regex().is_match(&"F".repeat(64))); // upper-case rejected } #[test] - fn tarball_sha256_regex_matches_64_hex() { - assert!(tarball_sha256_regex().is_match(&"f".repeat(64))); - assert!(!tarball_sha256_regex().is_match(&"f".repeat(63))); - assert!(!tarball_sha256_regex().is_match(&"F".repeat(64))); + fn source_uri_regex_accepts_any_scheme() { + assert!(source_uri_regex().is_match("https://example.com/src.tar.gz")); + assert!(source_uri_regex().is_match("http://example.com/foo.git")); + assert!(source_uri_regex().is_match("ipfs://Qm...abc")); + assert!(source_uri_regex().is_match("github:foo/bar")); + assert!(!source_uri_regex().is_match("foo/bar")); // no scheme + assert!(!source_uri_regex().is_match("https://has space")); // whitespace } #[test] @@ -1196,7 +1546,7 @@ mod tests { #[test] fn reserved_meta_keys_list() { - for key in ["bldimg", "source_rev", "bldopt"] { + for key in ["bldimg", "source_uri", "source_sha256", "bldopt"] { assert!(RESERVED_META_KEYS.contains(&key)); } } diff --git a/cmd/soroban-cli/src/config/data.rs b/cmd/soroban-cli/src/config/data.rs index e310ebf131..6e49f19262 100644 --- a/cmd/soroban-cli/src/config/data.rs +++ b/cmd/soroban-cli/src/config/data.rs @@ -58,6 +58,12 @@ pub fn bucket_dir() -> Result { Ok(dir) } +pub fn archives_dir() -> Result { + let dir = data_local_dir()?.join("archives"); + std::fs::create_dir_all(&dir)?; + Ok(dir) +} + pub fn write(action: Action, rpc_url: &Url) -> Result { let data = Data { action, @@ -211,6 +217,18 @@ mod test { use crate::test_utils::with_env_set; use serial_test::serial; + #[test] + #[serial] + fn archives_dir_under_data_home_and_created() { + let t = assert_fs::TempDir::new().unwrap(); + with_env_set("STELLAR_DATA_HOME", t.path(), || { + let dir = archives_dir().unwrap(); + assert!(dir.ends_with("archives")); + assert!(dir.starts_with(t.path())); + assert!(dir.is_dir(), "archives_dir() should create the directory"); + }); + } + #[test] #[serial] fn test_write_read() { From 5653489383f9ba8100c616cf10d3a7009f1923a4 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Tue, 16 Jun 2026 21:16:43 -0700 Subject: [PATCH 16/58] Record each contract's package in verifiable builds. --- .../src/commands/contract/build/verifiable.rs | 158 +++++++++++++----- 1 file changed, 115 insertions(+), 43 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 618ee15a97..e278ec961e 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -144,13 +144,8 @@ pub enum Error { #[error(transparent)] Data(#[from] data::Error), - #[error("container build exited with status {status}. To reproduce manually:\n docker run --rm -v {mount}:/source {image} {args}")] - ContainerExit { - status: i64, - image: String, - mount: String, - args: String, - }, + #[error("container build exited with status {status}. To reproduce manually:\n {command}")] + ContainerExit { status: i64, command: String }, } pub async fn run( @@ -247,31 +242,38 @@ pub async fn run( probe_supports_optimize_false_syntax(&image_ref, &docker, print).await }; - let package = resolve_build_package(cmd)?; - if cmd.package.is_none() { - if let Some(pkg) = &package { - print.infoln(format!( - "Inferred --package={pkg} and using it as a build option." - )); - } + // Build once per package, each with its own `--package` forwarded and + // recorded as a `bldopt`, so every WASM is independently reproducible. With + // no explicit `--package` the targets are inferred like a regular build. + let packages = resolve_build_packages(cmd)?; + if cmd.package.is_none() && !packages.is_empty() { + print.infoln(format!("Inferred packages: {}", packages.join(", "))); } - let (forwarded_args, bldopts) = build_forwarded_args( - cmd, - &source_root, - package.as_deref(), - supports_explicit_optimize_false, - ); - let metadata_args = build_metadata_args(&image_ref, &source_ids, &bldopts); - let container_cmd_args = compose_container_args(&forwarded_args, &metadata_args); + let targets: Vec> = if packages.is_empty() { + vec![None] + } else { + packages.iter().map(|p| Some(p.as_str())).collect() + }; + let container_cmds: Vec> = targets + .iter() + .map(|target| { + let (forwarded_args, bldopts) = + build_forwarded_args(cmd, &source_root, *target, supports_explicit_optimize_false); + let metadata_args = build_metadata_args(&image_ref, &source_ids, &bldopts); + compose_container_args(&forwarded_args, &metadata_args) + }) + .collect(); // Always stream the container's cargo output during `contract build // --verifiable`, matching how a non-verifiable `contract build` shows // cargo output by default. The verify-side caller gates this on - // `--verbose` because verifications are run as part of pipelines. + // `--verbose` because verifications are run as part of pipelines. All + // per-package builds run in one container so the crates download, compiled + // deps, and target/ are shared. run_in_container( &image_ref, &resolved.mount_root, - &container_cmd_args, + &container_cmds, &docker, print, true, @@ -602,16 +604,16 @@ fn source_uri_regex() -> Regex { Regex::new(r"^[a-zA-Z][a-zA-Z0-9+.-]*:\S+$").unwrap() } -/// Resolve the package to pin as `--package`. An explicit `--package` wins. -/// Otherwise, when the workspace builds exactly one cdylib by default, return -/// its name so the recorded bldopt is reproducible even if the workspace's -/// default members change later. Returns `None` when the selection is -/// ambiguous (zero or multiple default cdylibs) — the build then keeps cargo's -/// default behavior of building them all, which `--package` can't express -/// (the container's flag is singular). -fn resolve_build_package(cmd: &Cmd) -> Result, Error> { - if cmd.package.is_some() { - return Ok(cmd.package.clone()); +/// Resolve every package the build will produce, so each can be pinned with its +/// own `--package` (and recorded as a `bldopt`) — making each WASM independently +/// reproducible even if the workspace's default members change later. An +/// explicit `--package` wins; otherwise infer the default-member cdylibs exactly +/// like a regular `stellar contract build` does. May be empty (no cdylib default +/// members), in which case the caller falls back to a single no-`--package` +/// build. +fn resolve_build_packages(cmd: &Cmd) -> Result, Error> { + if let Some(pkg) = &cmd.package { + return Ok(vec![pkg.clone()]); } let mut mc = MetadataCommand::new(); mc.no_deps(); @@ -632,7 +634,7 @@ fn resolve_build_package(cmd: &Cmd) -> Result, Error> { .collect(); names.sort(); names.dedup(); - Ok((names.len() == 1).then(|| names.remove(0))) + Ok(names) } /// The flags forwarded to the container's `stellar contract build`, plus the @@ -980,18 +982,57 @@ async fn probe_cli_version(image_ref: &str, docker: &Docker) -> Result]) -> String { + cmds.iter() + .map(|cmd| { + std::iter::once("stellar") + .chain(cmd.iter().map(String::as_str)) + .map(|tok| shell_escape::escape(tok.into()).into_owned()) + .collect::>() + .join(" ") + }) + .collect::>() + .join(" && ") +} + async fn run_in_container( image_ref: &str, workspace_root: &Path, - container_cmd: &[String], + container_cmds: &[Vec], docker: &Docker, print: &Print, verbose: bool, ) -> Result<(), Error> { let bind = format!("{}:/source", workspace_root.display()); + + // One package → run the image's default `stellar` entrypoint directly. + // Several → override the entrypoint to a shell and chain the builds so they + // all run in this one container. + let (entrypoint, cmd, reproduce) = if container_cmds.len() > 1 { + let chain = compose_shell_command(container_cmds); + let reproduce = format!( + "docker run --rm -v {bind} --entrypoint /bin/sh {image_ref} -c {}", + shell_escape::escape(chain.clone().into()) + ); + ( + Some(vec!["/bin/sh".to_string(), "-c".to_string()]), + vec![chain], + reproduce, + ) + } else { + let cmd = container_cmds.first().cloned().unwrap_or_default(); + let reproduce = format!("docker run --rm -v {bind} {image_ref} {}", cmd.join(" ")); + (None, cmd, reproduce) + }; + let config = ContainerCreateBody { image: Some(image_ref.to_string()), - cmd: Some(container_cmd.to_vec()), + entrypoint, + cmd: Some(cmd), working_dir: Some("/source".to_string()), attach_stdout: Some(true), attach_stderr: Some(true), @@ -1051,18 +1092,14 @@ async fn run_in_container( Ok(r) => { return Err(Error::ContainerExit { status: r.status_code, - image: image_ref.to_string(), - mount: workspace_root.display().to_string(), - args: container_cmd.join(" "), + command: reproduce.clone(), }); } Err(bollard::errors::Error::DockerContainerWaitError { code: 0, .. }) => {} Err(bollard::errors::Error::DockerContainerWaitError { code, .. }) => { return Err(Error::ContainerExit { status: code, - image: image_ref.to_string(), - mount: workspace_root.display().to_string(), - args: container_cmd.join(" "), + command: reproduce.clone(), }); } Err(e) => return Err(e.into()), @@ -1550,4 +1587,39 @@ mod tests { assert!(RESERVED_META_KEYS.contains(&key)); } } + + #[test] + fn compose_shell_command_chains_and_escapes() { + let a = vec![ + "contract".to_string(), + "build".to_string(), + "--package=another".to_string(), + "--meta".to_string(), + "home_domain=fnando.com".to_string(), + ]; + let b = vec![ + "contract".to_string(), + "build".to_string(), + "--package=hello-world".to_string(), + ]; + let s = compose_shell_command(&[a, b]); + assert_eq!( + s, + "stellar contract build --package=another --meta home_domain=fnando.com \ + && stellar contract build --package=hello-world" + ); + + // A meta value with a space must be quoted so it stays one token. + let c = vec![ + "contract".to_string(), + "build".to_string(), + "--meta".to_string(), + "note=added on build".to_string(), + ]; + let s = compose_shell_command(&[c]); + assert!( + s.contains("'note=added on build'") || s.contains("\"note=added on build\""), + "expected the spaced value to be quoted, got: {s}" + ); + } } From 4518c1acb8e675c1ff7639c60292bd73755ba6e8 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Tue, 16 Jun 2026 22:04:02 -0700 Subject: [PATCH 17/58] Document reproducible source archive guarantees. --- .../src/commands/contract/build/verifiable.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index e278ec961e..faa45b9213 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -485,8 +485,18 @@ fn git_archive_tar(source_root: &Path) -> Result, Error> { } /// Tar the working tree under `source_root`, skipping denylisted path -/// components, with entries sorted and headers normalized (deterministic mode) -/// so the bytes are reproducible. Each entry is prefixed with `source/`. +/// components. Each entry is prefixed with `source/`. +/// +/// The output is reproducible, following GNU tar's reproducibility guidance +/// () +/// with the portable equivalents available via the `tar` crate (the system +/// `tar` can't be relied on — macOS ships bsdtar, which lacks `--sort`, +/// `--mtime`, `--pax-option`, …): entries are sorted by name (`--sort=name`) +/// using locale-independent path ordering (`LC_ALL=C`), and `HeaderMode::Deterministic` +/// zeroes mtime (`--mtime`/`--clamp-mtime`), sets uid/gid to 0 with empty owner +/// names (`--owner=0 --group=0 --numeric-owner`), and normalizes mode +/// (`--mode=go+u,go-w`). ustar headers carry no atime/ctime or tar PID. The gzip +/// wrapper (see `gzip`) is likewise deterministic. fn walk_tar(source_root: &Path) -> Result, Error> { let mut files: Vec = Vec::new(); let walk = WalkDir::new(source_root) @@ -1482,6 +1492,11 @@ mod tests { assert!(!dest.path().join("source/target").exists()); assert!(!dest.path().join("source/.git").exists()); assert_eq!(hex::encode(Sha256::digest(&bytes)).len(), 64); + + // Reproducible: a second run over the same tree yields identical bytes + // (sorted entries + zeroed header fields + deterministic gzip). + let again = build_source_archive(root, &print).unwrap(); + assert_eq!(bytes, again); } #[test] From 36552c9beff5731ab4a7c8f4db99298c8de102f8 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 17 Jun 2026 09:54:42 -0700 Subject: [PATCH 18/58] Add stellar contract archive command. --- FULL_HELP_DOCS.md | 16 +- cmd/crates/soroban-test/tests/it/build.rs | 113 +++- .../src/commands/contract/archive.rs | 126 +++++ .../src/commands/contract/build.rs | 26 +- .../commands/contract/build/source_archive.rs | 450 ++++++++++++++++ .../src/commands/contract/build/verifiable.rs | 484 ++---------------- .../src/commands/contract/fetch.rs | 4 + cmd/soroban-cli/src/commands/contract/mod.rs | 8 + 8 files changed, 743 insertions(+), 484 deletions(-) create mode 100644 cmd/soroban-cli/src/commands/contract/archive.rs create mode 100644 cmd/soroban-cli/src/commands/contract/build/source_archive.rs diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index e6af930448..d48ab89939 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -84,6 +84,7 @@ Tools for smart contract developers - `asset` — Utilities to deploy a Stellar Asset Contract or get its id - `alias` — Utilities to manage contract aliases - `bindings` — Generate code client bindings for a contract +- `archive` — Generate the reproducible source archive used by verifiable builds - `build` — Build a contract from source - `extend` — Extend the time to live ledger of a contract-data ledger entry - `deploy` — Deploy a wasm contract @@ -344,6 +345,18 @@ Generate PHP bindings **Usage:** `stellar contract bindings php` +## `stellar contract archive` + +Generate the reproducible source archive used by verifiable builds + +**Usage:** `stellar contract archive [OPTIONS]` + +###### **Options:** + +- `-o`, `--out-file ` — Where to write the gzipped tarball. Required unless `--dry-run` is used +- `--manifest-path ` — Path to Cargo.toml, used to locate the source root (its enclosing git repository, or the working directory) +- `--dry-run` — List the entries that would be archived and the computed source_sha256, without writing any file + ## `stellar contract build` Build a contract from source @@ -398,9 +411,8 @@ 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/tree (recorded as the `source_sha256` meta entry). Required with `--verifiable` unless `--archive` is used, which generates the archive and computes this for you +- `--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` -- `--archive ` — Generate a source archive for the verifiable build, then build from it and record its SHA-256 as the SEP-58 `source_sha256` meta entry. Pass a path to choose where the gzipped tarball is written; with no path it goes to the data dir's `archives/`. In a git repo the archive is `git archive HEAD`; otherwise the working directory is archived minus a built-in denylist (.git, .svn, .hg, target/, node_modules/, .DS_Store) - `-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 9733d37fb6..dfe798e982 100644 --- a/cmd/crates/soroban-test/tests/it/build.rs +++ b/cmd/crates/soroban-test/tests/it/build.rs @@ -1088,13 +1088,16 @@ fn verifiable_image_requires_explicit_registry_host() { .stderr(predicate::str::contains("bldimg format")); } -// `--verifiable` with neither `--source-sha256` nor `--archive` must error. -// Run in a fresh (non-git) workspace so the clean-tree check is skipped and the -// missing-source error is what surfaces. +// `--verifiable` always generates the source archive (and computes +// source_sha256) before the docker stage, so the "Wrote source archive" line +// appears even though the build then fails to reach a real image. #[test] -fn verifiable_requires_source_sha256() { +fn verifiable_always_writes_source_archive() { 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") @@ -1105,39 +1108,111 @@ fn verifiable_requires_source_sha256() { .arg(ZERO_DIGEST) .assert() .failure() - .stderr(predicate::str::contains("--source-sha256")); + .stderr( + predicate::str::contains("Wrote source archive") + .and(predicate::str::contains("source_sha256")), + ); } -// `--archive` generates the source archive (and computes source_sha256) before -// the docker stage, so the file is written even though the build then fails to -// reach a real image. +// `contract archive --out` writes the gzipped tarball and prints its +// source_sha256. #[test] -fn verifiable_archive_writes_source_archive() { +fn contract_archive_writes_out() { 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"]); - let archive_path = temp.path().join("src.tar.gz"); + let out = temp.path().join("src.tar.gz"); sandbox .new_assert_cmd("contract") - .current_dir(workspace.join("contracts").join("add")) - .arg("build") - .arg("--verifiable") - .arg("--image") - .arg(ZERO_DIGEST) - .arg(format!("--archive={}", archive_path.display())) + .current_dir(&workspace) + .arg("archive") + .arg("--out-file") + .arg(&out) .assert() - .failure(); + .success() + .stderr( + predicate::str::contains("Wrote source archive") + .and(predicate::str::contains("source_sha256")), + ); + assert!(out.exists(), "the archive should be written to --out"); assert!( - archive_path.exists(), - "source archive should be written before the docker stage" + std::fs::metadata(&out).unwrap().len() > 0, + "the archive should not be empty" ); } +// `contract archive --dry-run` lists the archived entries and the +// source_sha256 without writing any file. +#[test] +fn contract_archive_dry_run_lists_entries() { + 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"]); + + let out = temp.path().join("should-not-exist.tar.gz"); + + sandbox + .new_assert_cmd("contract") + .current_dir(&workspace) + .arg("archive") + .arg("--dry-run") + .assert() + .success() + .stdout(predicate::str::contains("source/Cargo.toml")) + .stderr(predicate::str::contains("source_sha256")); + + assert!(!out.exists(), "--dry-run must not write an archive"); +} + +// `--out-file` must name a gzipped tarball (.tar.gz / .tgz). +#[test] +fn contract_archive_rejects_bad_out_file_extension() { + 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"]); + + let out = temp.path().join("src.zip"); + + sandbox + .new_assert_cmd("contract") + .current_dir(&workspace) + .arg("archive") + .arg("--out-file") + .arg(&out) + .assert() + .failure() + .stderr(predicate::str::contains(".tar.gz or .tgz")); + + assert!( + !out.exists(), + "no archive should be written on a bad extension" + ); +} + +// `--out-file` is required unless `--dry-run` is passed. +#[test] +fn contract_archive_requires_out_file_without_dry_run() { + let sandbox = TestEnv::default(); + let (_temp, workspace) = fresh_workspace(); + + sandbox + .new_assert_cmd("contract") + .current_dir(&workspace) + .arg("archive") + .assert() + .failure() + .stderr(predicate::str::contains("--out-file")); +} + // `--source-sha256` value must match the 64-hex regex. #[test] fn verifiable_source_sha256_format_errors() { diff --git a/cmd/soroban-cli/src/commands/contract/archive.rs b/cmd/soroban-cli/src/commands/contract/archive.rs new file mode 100644 index 0000000000..d4fe4cef03 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/archive.rs @@ -0,0 +1,126 @@ +use std::path::PathBuf; + +use clap::Parser; +use sha2::{Digest, Sha256}; + +use crate::{commands::global, print::Print}; + +use super::build::source_archive; + +/// Accepted `--out-file` suffixes (lower-case). The archive is always a gzipped +/// tarball, so the filename must say so. +const ARCHIVE_EXTENSIONS: &[&str] = &[".tar.gz", ".tgz"]; + +/// Generate (or inspect) the reproducible source archive for a contract. +/// +/// Produces the same gzipped tarball that `stellar contract build --verifiable` +/// builds from, and prints its SHA-256 (the SEP-58 `source_sha256`). Use +/// `--dry-run` to list exactly what would be archived without writing anything — +/// handy for confirming the contents before a verifiable build, or for +/// producing the archive to host at a `--source-uri`. +/// +/// In a git repo the archive is `git archive HEAD` (the committed tree); +/// otherwise the working directory is archived minus a built-in denylist (.git, +/// target/, node_modules/, .DS_Store, …). +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + /// Where to write the gzipped tarball. Required unless `--dry-run` is used. + #[arg(long, short = 'o', required_unless_present = "dry_run")] + pub out_file: Option, + + /// Path to Cargo.toml, used to locate the source root (its enclosing git + /// repository, or the working directory). + #[arg(long)] + pub manifest_path: Option, + + /// List the entries that would be archived and the computed source_sha256, + /// without writing any file. + #[arg(long)] + pub dry_run: bool, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + SourceArchive(#[from] source_archive::Error), + + #[error( + "--out-file {0} must end in .tar.gz or .tgz (the archive is always a gzipped tarball)" + )] + OutFileExtension(String), +} + +impl Cmd { + pub fn run(&self, global_args: &global::Args) -> Result<(), Error> { + let print = Print::new(global_args.quiet); + + let source_root = source_archive::resolve_source_root(self.manifest_path.as_deref()); + + // The git path archives HEAD, so uncommitted changes are silently + // excluded. Warn (don't fail — this is an inspect/generate tool, not a + // build) so the printed source_sha256 isn't mistaken for the working + // tree's. + if source_archive::tree_is_dirty(&source_root)? { + print.warnln(format!( + "git working tree at {} is dirty; the archive reflects HEAD only and excludes uncommitted changes.", + source_root.display(), + )); + } + + // The dry-run listing itself reveals the contents, so skip the + // "not a git repository" warning there. + let bytes = source_archive::build_source_archive(&source_root, &print, !self.dry_run)?; + let sha = hex::encode(Sha256::digest(&bytes)); + + if self.dry_run { + let names = source_archive::entry_names(&bytes)?; + let prefix = print.compute_emoji("📄"); + + for name in &names { + println!("{prefix} {name}"); + } + print.infoln(format!("{} files", names.len())); + print.infoln(format!("source_sha256 {sha}")); + return Ok(()); + } + + // `--out-file` is required when not `--dry-run`, so this is always set here. + let out = self + .out_file + .as_ref() + .expect("--out-file is required without --dry-run"); + + // The output is always a gzipped tarball, so require a matching + // extension to keep the filename honest. + let name = out + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_ascii_lowercase(); + if !ARCHIVE_EXTENSIONS.iter().any(|ext| name.ends_with(ext)) { + return Err(Error::OutFileExtension(out.display().to_string())); + } + + if let Some(parent) = out.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|source| { + source_archive::Error::ArchiveWrite { + path: out.clone(), + source, + } + })?; + } + } + std::fs::write(out, &bytes).map_err(|source| source_archive::Error::ArchiveWrite { + path: out.clone(), + source, + })?; + print.checkln(format!( + "Wrote source archive {} (source_sha256 {sha})", + out.display() + )); + + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/contract/build.rs b/cmd/soroban-cli/src/commands/contract/build.rs index 9ad08ca7fd..e8fb9c0c4b 100644 --- a/cmd/soroban-cli/src/commands/contract/build.rs +++ b/cmd/soroban-cli/src/commands/contract/build.rs @@ -25,6 +25,7 @@ use crate::{ wasm, }; +pub(crate) mod source_archive; pub mod verifiable; /// A built WASM artifact with its package name and file path. @@ -111,10 +112,11 @@ pub struct Cmd { #[arg(long, requires = "verifiable", help_heading = "Verifiable")] pub image: Option, - /// SEP-58 source identification: SHA-256 of the source archive/tree - /// (recorded as the `source_sha256` meta entry). Required with - /// `--verifiable` unless `--archive` is used, which generates the archive - /// and computes this for you. + /// 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. #[arg(long, requires = "verifiable", help_heading = "Verifiable")] pub source_sha256: Option, @@ -129,21 +131,6 @@ pub struct Cmd { )] pub source_uri: Option, - /// Generate a source archive for the verifiable build, then build from it - /// and record its SHA-256 as the SEP-58 `source_sha256` meta entry. Pass a - /// path to choose where the gzipped tarball is written; with no path it - /// goes to the data dir's `archives/`. In a git repo the archive is - /// `git archive HEAD`; otherwise the working directory is archived minus a - /// built-in denylist (.git, .svn, .hg, target/, node_modules/, .DS_Store). - #[arg( - long, - num_args = 0..=1, - require_equals = true, - requires = "verifiable", - help_heading = "Verifiable" - )] - pub archive: Option>, - /// Override the default docker host used by `--verifiable`. #[arg(short = 'd', long, env = "DOCKER_HOST", help_heading = "Verifiable")] pub docker_host: Option, @@ -281,7 +268,6 @@ impl Default for Cmd { image: None, source_sha256: None, source_uri: None, - archive: None, docker_host: None, build_args: BuildArgs::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 new file mode 100644 index 0000000000..2f20d516cd --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs @@ -0,0 +1,450 @@ +//! Reproducible source-archive generation for verifiable builds. +//! +//! Produces a gzipped tarball of a contract's source tree, rooted under a +//! top-level `source/` prefix (so it extracts to a `source/` dir, mirroring the +//! container's `/source` mount). In a git repo this is `git archive HEAD` (the +//! committed tree); otherwise the working directory is walked and tarred, +//! skipping `ARCHIVE_DENYLIST` entries. The output is byte-reproducible, so the +//! same tree always hashes to the same `source_sha256`. +//! +//! Shared by `contract build --verifiable` (which builds from the extracted +//! archive) and the standalone `contract archive` command (which generates and +//! inspects it). + +use std::{ + io::Write, + path::{Path, PathBuf}, + process::Command, +}; + +use walkdir::WalkDir; + +use crate::print::Print; + +/// Top-level names excluded when archiving a non-git working directory (we have +/// no tracked-files list to consult, so fall back to a fixed denylist of VCS +/// metadata, build/cache/transient dirs, and editor/OS/AI-assistant junk). +/// Matched against each path component, so a directory like `target/` prunes +/// its whole subtree. +pub(crate) const ARCHIVE_DENYLIST: &[&str] = &[ + // version control + ".git", + ".gitignore", + ".svn", + ".hg", + // secrets / local environment + ".env", + // build output / dependencies + "target", + "node_modules", + // transient + "log", + "logs", + "tmp", + "temp", + // OS / editor junk + ".DS_Store", + "Thumbs.db", + ".idea", + ".vscode", + // AI assistant dirs + ".claude", + ".cursor", + ".windsurf", + ".aider", +]; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("could not read git state at {path}: {source}")] + GitInvoke { + path: PathBuf, + source: std::io::Error, + }, + + #[error("`git archive` failed in {path}: {stderr}")] + GitArchive { path: PathBuf, stderr: String }, + + #[error("could not write source archive to {path}: {source}")] + ArchiveWrite { + path: PathBuf, + source: std::io::Error, + }, + + #[error("could not extract source archive: {0}")] + ArchiveExtract(std::io::Error), +} + +/// Pick the anchor for the source tree: the directory whose `.git` parent we +/// archive (and, for verifiable builds, relativize `--manifest-path` against). +/// Walk up from `manifest_path` (or cwd, if none) looking for a `.git` +/// directory; return its parent. If none is found, fall back to cwd. +/// +/// This isn't a validation step — any `.git` will do. Wrong-source mistakes are +/// caught later by the verify-side byte comparison. +pub(crate) fn resolve_source_root(manifest_path: Option<&Path>) -> PathBuf { + let start = if let Some(p) = manifest_path { + let abs = std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()); + abs.parent().map(Path::to_path_buf).unwrap_or(abs) + } else { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + }; + + let mut p = start.clone(); + loop { + if p.join(".git").exists() { + return p; + } + if !p.pop() { + break; + } + } + + std::env::current_dir().unwrap_or(start) +} + +/// Whether `source_root` is inside a git work tree. +pub(crate) fn is_git_repo(source_root: &Path) -> bool { + Command::new("git") + .arg("-C") + .arg(source_root) + .arg("rev-parse") + .arg("--is-inside-work-tree") + .output() + .is_ok_and(|o| o.status.success()) +} + +/// Whether `source_root` is a git work tree with uncommitted changes. Returns +/// `Ok(false)` when it isn't a git repo (git ran but refused) — callers can't +/// verify cleanliness there, so they proceed. Errors only when git can't be +/// invoked at all. +pub(crate) fn tree_is_dirty(source_root: &Path) -> Result { + let status = Command::new("git") + .arg("-C") + .arg(source_root) + .arg("status") + .arg("--porcelain") + .output() + .map_err(|source| Error::GitInvoke { + path: source_root.to_path_buf(), + source, + })?; + + // Not a git repo (or git refused): can't verify cleanliness, proceed. + if !status.status.success() { + return Ok(false); + } + + Ok(!status.stdout.is_empty()) +} + +/// Produce the gzipped source tarball bytes. Entries are rooted under a +/// top-level `source/` prefix. In a git repo this is `git archive HEAD` (the +/// committed tree); otherwise the working directory is walked and tarred, +/// skipping `ARCHIVE_DENYLIST` entries. +/// +/// When the source isn't a git repo, `warn_non_git` controls whether to warn +/// that the working directory is being archived. Callers that only inspect the +/// result (e.g. `contract archive --dry-run`) pass `false`, since the listing +/// itself reveals the contents. +pub(crate) fn build_source_archive( + source_root: &Path, + print: &Print, + warn_non_git: bool, +) -> Result, Error> { + let tar = if is_git_repo(source_root) { + git_archive_tar(source_root)? + } else { + if warn_non_git { + print.warnln(format!( + "{} is not a git repository; archiving the working directory. Inspect the generated archive to confirm its contents.", + source_root.display(), + )); + } + walk_tar(source_root)? + }; + gzip(&tar) +} + +/// Tar entry paths inside the gzipped archive bytes, in archive order. Used by +/// `contract archive --dry-run` to list exactly what the bytes that hash to +/// `source_sha256` contain. +pub(crate) fn entry_names(bytes: &[u8]) -> Result, Error> { + let dec = flate2::read::GzDecoder::new(bytes); + let mut archive = tar::Archive::new(dec); + let mut names = Vec::new(); + for entry in archive.entries().map_err(Error::ArchiveExtract)? { + let entry = entry.map_err(Error::ArchiveExtract)?; + let path = entry.path().map_err(Error::ArchiveExtract)?; + names.push(path.to_string_lossy().into_owned()); + } + Ok(names) +} + +/// `git archive --format=tar --prefix=source/ HEAD`, returning the tar bytes. +fn git_archive_tar(source_root: &Path) -> Result, Error> { + let out = Command::new("git") + .arg("-C") + .arg(source_root) + .arg("archive") + .arg("--format=tar") + .arg("--prefix=source/") + .arg("HEAD") + .output() + .map_err(|source| Error::GitInvoke { + path: source_root.to_path_buf(), + source, + })?; + if !out.status.success() { + return Err(Error::GitArchive { + path: source_root.to_path_buf(), + stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(), + }); + } + Ok(out.stdout) +} + +/// Tar the working tree under `source_root`, skipping denylisted path +/// components. Each entry is prefixed with `source/`. +/// +/// The output is reproducible, following GNU tar's reproducibility guidance +/// () +/// with the portable equivalents available via the `tar` crate (the system +/// `tar` can't be relied on — macOS ships bsdtar, which lacks `--sort`, +/// `--mtime`, `--pax-option`, …): entries are sorted by name (`--sort=name`) +/// using locale-independent path ordering (`LC_ALL=C`), and `HeaderMode::Deterministic` +/// zeroes mtime (`--mtime`/`--clamp-mtime`), sets uid/gid to 0 with empty owner +/// names (`--owner=0 --group=0 --numeric-owner`), and normalizes mode +/// (`--mode=go+u,go-w`). ustar headers carry no atime/ctime or tar PID. The gzip +/// wrapper (see `gzip`) is likewise deterministic. +fn walk_tar(source_root: &Path) -> Result, Error> { + let mut files: Vec = Vec::new(); + let walk = WalkDir::new(source_root) + .sort_by_file_name() + .into_iter() + .filter_entry(|e| !is_denylisted(e.file_name())); + for entry in walk { + let entry = entry.map_err(|e| Error::ArchiveWrite { + path: source_root.to_path_buf(), + source: e.into(), + })?; + if entry.file_type().is_file() { + files.push(entry.path().to_path_buf()); + } + } + files.sort(); + + let mut builder = tar::Builder::new(Vec::new()); + builder.mode(tar::HeaderMode::Deterministic); + for path in &files { + let rel = path.strip_prefix(source_root).unwrap_or(path); + let name = Path::new("source").join(rel); + let mut f = std::fs::File::open(path).map_err(|source| Error::ArchiveWrite { + path: path.clone(), + source, + })?; + builder + .append_file(&name, &mut f) + .map_err(|source| Error::ArchiveWrite { + path: path.clone(), + source, + })?; + } + builder.into_inner().map_err(|source| Error::ArchiveWrite { + path: source_root.to_path_buf(), + source, + }) +} + +/// A path component is denylisted if it equals a denylist entry, or — for +/// dotted entries, which double as extension filters (e.g. `.swp`, `.log`) — if +/// it ends with that entry. Plain names (`target`, `node_modules`) match +/// exactly only, so `mytarget` is not excluded. +fn is_denylisted(name: &std::ffi::OsStr) -> bool { + let name = name.to_string_lossy(); + ARCHIVE_DENYLIST + .iter() + .any(|d| name == *d || (d.starts_with('.') && name.ends_with(d))) +} + +/// Gzip with a default (mtime-zeroed) header so the same tar bytes always hash +/// the same. +fn gzip(bytes: &[u8]) -> Result, Error> { + let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + enc.write_all(bytes).map_err(|source| Error::ArchiveWrite { + path: PathBuf::new(), + source, + })?; + enc.finish().map_err(|source| Error::ArchiveWrite { + path: PathBuf::new(), + source, + }) +} + +/// Decompress gzip and unpack the tar into `dest`. Entries are `source/…`, so +/// they land at `/source/…`. +pub(crate) fn unpack_targz(bytes: &[u8], dest: &Path) -> Result<(), Error> { + let dec = flate2::read::GzDecoder::new(bytes); + tar::Archive::new(dec) + .unpack(dest) + .map_err(Error::ArchiveExtract) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::locator::enforce_hardened_tree; + use sha2::{Digest, Sha256}; + + #[test] + fn is_denylisted_matches_names_and_dotted_suffixes() { + use std::ffi::OsStr; + // exact name matches + assert!(is_denylisted(OsStr::new("target"))); + assert!(is_denylisted(OsStr::new(".git"))); + assert!(is_denylisted(OsStr::new(".gitignore"))); + assert!(is_denylisted(OsStr::new(".env"))); + assert!(is_denylisted(OsStr::new(".DS_Store"))); + // plain names match exactly only + assert!(!is_denylisted(OsStr::new("mytarget"))); + assert!(!is_denylisted(OsStr::new("targets"))); + // dotted entries also match as suffix (extension-style) + assert!(is_denylisted(OsStr::new("backup.git"))); + // unrelated files pass through + assert!(!is_denylisted(OsStr::new("Cargo.toml"))); + assert!(!is_denylisted(OsStr::new("lib.rs"))); + } + + // Initialize a git repo at `root` with one commit of everything present. + #[cfg(unix)] + fn git_init_commit(root: &Path) { + for args in [ + &["init", "-q", "-b", "main"][..], + &["add", "-A"][..], + &["commit", "-q", "-m", "init"][..], + ] { + let ok = Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .env("GIT_AUTHOR_NAME", "T") + .env("GIT_AUTHOR_EMAIL", "t@e.x") + .env("GIT_COMMITTER_NAME", "T") + .env("GIT_COMMITTER_EMAIL", "t@e.x") + .status() + .unwrap() + .success(); + assert!(ok); + } + } + + #[test] + #[cfg(unix)] + fn build_source_archive_git_is_prefixed_and_deterministic() { + use std::os::unix::fs::PermissionsExt; + let print = Print::new(true); + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + std::fs::write(root.join("Cargo.toml"), b"# crate").unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("src/lib.rs"), b"// code").unwrap(); + git_init_commit(root); + + let a = build_source_archive(root, &print, true).unwrap(); + let b = build_source_archive(root, &print, true).unwrap(); + assert!(!a.is_empty()); + assert_eq!(a, b, "same commit should produce identical bytes"); + + let sha = hex::encode(Sha256::digest(&a)); + assert_eq!(sha.len(), 64); + + // The listing reflects exactly the archived entries. + let names = entry_names(&a).unwrap(); + assert!(names.iter().any(|n| n == "source/Cargo.toml")); + assert!(names.iter().any(|n| n == "source/src/lib.rs")); + + // Unpack and confirm the `source/` prefix + hardened perms. + let dest = tempfile::TempDir::new().unwrap(); + unpack_targz(&a, dest.path()).unwrap(); + assert!(dest.path().join("source/Cargo.toml").exists()); + assert!(dest.path().join("source/src/lib.rs").exists()); + + enforce_hardened_tree(dest.path()).unwrap(); + let file_mode = std::fs::metadata(dest.path().join("source/Cargo.toml")) + .unwrap() + .permissions() + .mode() + & 0o777; + let dir_mode = std::fs::metadata(dest.path().join("source")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(file_mode, 0o600); + assert_eq!(dir_mode, 0o700); + } + + #[test] + fn build_source_archive_non_git_excludes_denylist() { + let print = Print::new(true); + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + std::fs::write(root.join("Cargo.toml"), b"# crate").unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("src/lib.rs"), b"// code").unwrap(); + // Planted dirs that must be excluded. + std::fs::create_dir_all(root.join("target/debug")).unwrap(); + std::fs::write(root.join("target/debug/x"), b"junk").unwrap(); + std::fs::create_dir_all(root.join(".git")).unwrap(); + std::fs::write(root.join(".git/config"), b"junk").unwrap(); + + let bytes = build_source_archive(root, &print, true).unwrap(); + let dest = tempfile::TempDir::new().unwrap(); + unpack_targz(&bytes, dest.path()).unwrap(); + + assert!(dest.path().join("source/Cargo.toml").exists()); + assert!(dest.path().join("source/src/lib.rs").exists()); + assert!(!dest.path().join("source/target").exists()); + assert!(!dest.path().join("source/.git").exists()); + assert_eq!(hex::encode(Sha256::digest(&bytes)).len(), 64); + + // Reproducible: a second run over the same tree yields identical bytes + // (sorted entries + zeroed header fields + deterministic gzip). + let again = build_source_archive(root, &print, true).unwrap(); + assert_eq!(bytes, again); + } + + #[test] + fn resolve_source_root_finds_git_root_from_subdir() { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + std::fs::create_dir_all(root.join(".git")).unwrap(); + let nested = root.join("contracts").join("foo"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(nested.join("Cargo.toml"), b"# placeholder").unwrap(); + + let manifest = nested.join("Cargo.toml"); + // Use canonicalize on both sides — `tempfile` returns symlinked /var + // paths on macOS while resolve_source_root walks the same prefix. + let got = std::fs::canonicalize(resolve_source_root(Some(&manifest))).unwrap(); + let want = std::fs::canonicalize(root).unwrap(); + assert_eq!(got, want); + } + + #[test] + fn resolve_source_root_falls_back_to_cwd_without_git() { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + let nested = root.join("noisy"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(nested.join("Cargo.toml"), b"# placeholder").unwrap(); + + let manifest = nested.join("Cargo.toml"); + // No `.git` anywhere up the tree, so we fall back to cwd. We can't + // assert what cwd is in a test runner (it varies), but we can assert + // that the returned path doesn't have `.git`. That's enough to confirm + // fallback kicked in. + let got = resolve_source_root(Some(&manifest)); + assert!(!got.join(".git").exists()); + } +} diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index faa45b9213..75d89dad63 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -1,8 +1,4 @@ -use std::{ - io::Write, - path::{Path, PathBuf}, - process::Command, -}; +use std::path::{Path, PathBuf}; use bollard::{ models::ContainerCreateBody, @@ -19,7 +15,6 @@ use regex::Regex; use semver::Version; use serde::Deserialize; use sha2::{Digest, Sha256}; -use walkdir::WalkDir; use crate::{ commands::{container::shared::Error as ConnectionError, global}, @@ -27,43 +22,13 @@ use crate::{ print::Print, }; -use super::{BuiltContract, Cmd, WASM_TARGET}; +use super::{source_archive, BuiltContract, Cmd, WASM_TARGET}; const REGISTRY: &str = "docker.io/stellar/stellar-cli"; const HUB_TAGS_URL: &str = "https://hub.docker.com/v2/repositories/stellar/stellar-cli/tags/?page_size=100"; const RESERVED_META_KEYS: &[&str] = &["bldimg", "source_uri", "source_sha256", "bldopt"]; -/// Top-level names excluded when archiving a non-git working directory (we have -/// no tracked-files list to consult, so fall back to a fixed denylist of VCS -/// metadata, build/cache/transient dirs, and editor/OS/AI-assistant junk). -/// Matched against each path component, so a directory like `target/` prunes -/// its whole subtree. -const ARCHIVE_DENYLIST: &[&str] = &[ - // version control - ".git", - ".svn", - ".hg", - // build output / dependencies - "target", - "node_modules", - // transient - "log", - "logs", - "tmp", - "temp", - // OS / editor junk - ".DS_Store", - "Thumbs.db", - ".idea", - ".vscode", - // AI assistant dirs - ".claude", - ".cursor", - ".windsurf", - ".aider", -]; - /// First cli release that accepts `--optimize=false` as an explicit value /// (added by commit `b17d3f0b`). Containers older than this only accept bare /// `--optimize`; we probe the container's `stellar version --only-version` to @@ -101,11 +66,8 @@ pub enum Error { #[error("cargo metadata failed: {0}")] Metadata(#[from] cargo_metadata::Error), - #[error("could not read git state at {path}: {source}")] - GitInvoke { - path: PathBuf, - source: std::io::Error, - }, + #[error(transparent)] + SourceArchive(#[from] source_archive::Error), #[error( "git working tree at {path} is dirty. --verifiable requires a clean tree so the recorded source_sha256 matches the WASM bytes. Commit or stash your changes and try again." @@ -117,9 +79,6 @@ pub enum Error { )] ReservedMetaKey { key: String }, - #[error("--verifiable requires --source-sha256 (the SEP-58 source_sha256: 64-char hex SHA-256 of the source), or --archive to generate the source archive and compute it. --source-uri is optional.")] - MissingSourceSha256, - #[error("--source-sha256 value {value:?} does not match the SEP-58 source_sha256 format `^[0-9a-f]{{64}}$` (64-char lower-case hex).")] SourceSha256Format { value: String }, @@ -129,18 +88,6 @@ pub enum Error { #[error("--source-sha256 {provided} does not match the SHA-256 of the generated archive {computed}. Omit --source-sha256 to record the computed value, or fix the value.")] SourceSha256Mismatch { provided: String, computed: String }, - #[error("`git archive` failed in {path}: {stderr}")] - GitArchive { path: PathBuf, stderr: String }, - - #[error("could not write source archive to {path}: {source}")] - ArchiveWrite { - path: PathBuf, - source: std::io::Error, - }, - - #[error("could not extract source archive: {0}")] - ArchiveExtract(std::io::Error), - #[error(transparent)] Data(#[from] data::Error), @@ -174,42 +121,29 @@ pub async fn run( // gets bind-mounted into the container. We do NOT validate that it matches // source_uri — a wrong source produces different bytes, and verify catches // that at byte-comparison time. - let source_root = resolve_source_root(cmd); + let source_root = source_archive::resolve_source_root(cmd.manifest_path.as_deref()); // A dirty working tree would make the recorded source_sha256 fail to // describe the bytes actually built, so refuse to proceed. Skipped when // the source root isn't a git repo (we can't check, e.g. archive sources). enforce_clean_tree(&source_root)?; - // Resolve the recorded source_sha256 and the directory the container mounts - // at /source. With `--archive`, the CLI builds the source archive, records - // its hash, and builds from the *extracted* archive (in a hardened tempdir) - // so the WASM is produced from exactly the bytes that were hashed. Without - // it, the user supplies --source-sha256 and we mount the working tree. - let resolved = match &cmd.archive { - Some(_) => { - let a = resolve_archive(cmd, &source_root, print)?; - // The extracted `source/` dir mirrors `source_root` exactly and is - // both the container mount and the tree the build writes `target/` - // into, so it's what `collect_built_contracts` resolves artifacts - // against. - let mount_root = a.extracted_root.join("source"); - ResolvedSource { - source_sha256: a.source_sha256, - extracted_root: Some(mount_root.clone()), - mount_root, - _tmp: Some(a.tmp), - } + // Always build the source archive, record its hash, and build from the + // *extracted* archive (in a hardened tempdir) so the WASM is produced from + // exactly the bytes that were hashed. A `--source-sha256` passed by the user + // is treated as a pin and validated against the computed hash. + let resolved = { + let a = resolve_archive(cmd, &source_root, print)?; + // The extracted `source/` dir mirrors `source_root` exactly and is both + // the container mount and the tree the build writes `target/` into, so + // it's what `collect_built_contracts` resolves artifacts against. + let mount_root = a.extracted_root.join("source"); + ResolvedSource { + source_sha256: a.source_sha256, + extracted_root: Some(mount_root.clone()), + mount_root, + _tmp: Some(a.tmp), } - None => ResolvedSource { - source_sha256: cmd - .source_sha256 - .clone() - .ok_or(Error::MissingSourceSha256)?, - mount_root: source_root.clone(), - extracted_root: None, - _tmp: None, - }, }; let source_ids = SourceIds { @@ -285,9 +219,9 @@ pub async fn run( collect_built_contracts(cmd, &source_root, resolved.extracted_root.as_deref(), print) } -/// The recorded `source_sha256`, the directory bind-mounted at `/source`, and -/// (when `--archive` is used) the extracted-archive root plus its tempdir guard -/// — held so the temp dir outlives the container build and artifact collection. +/// The recorded `source_sha256`, the directory bind-mounted at `/source`, the +/// extracted-archive root, and its tempdir guard — held so the temp dir +/// outlives the container build and artifact collection. struct ResolvedSource { source_sha256: String, mount_root: PathBuf, @@ -305,34 +239,6 @@ fn resolve_workspace_root(cmd: &Cmd) -> Result { Ok(md.workspace_root.into_std_path_buf()) } -/// Pick the anchor for the container bind-mount and for relativizing -/// `--manifest-path` into the recorded `bldopt`. Walk up from the user's -/// `--manifest-path` (or cwd, if no manifest_path) looking for a `.git` -/// directory; return its parent. If none is found, fall back to cwd. -/// -/// This isn't a validation step — any `.git` will do. Wrong-source mistakes -/// are caught later by the verify-side byte comparison. -fn resolve_source_root(cmd: &Cmd) -> PathBuf { - let start = if let Some(p) = &cmd.manifest_path { - let abs = std::path::absolute(p).unwrap_or_else(|_| p.clone()); - abs.parent().map(Path::to_path_buf).unwrap_or(abs) - } else { - std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) - }; - - let mut p = start.clone(); - loop { - if p.join(".git").exists() { - return p; - } - if !p.pop() { - break; - } - } - - std::env::current_dir().unwrap_or(start) -} - /// Source-identification fields recorded as SEP-58 meta. `source_sha256` is /// always `Some` by the time these are built in `run()` (resolved from /// `--source-sha256` or computed from the generated archive). `source_uri` is @@ -343,8 +249,9 @@ struct SourceIds { source_sha256: Option, } -/// Format-validate the user-supplied source flags. Requiredness is enforced in -/// `run()` (it depends on whether `--archive` is used), not here. +/// Format-validate the user-supplied source flags. Both are optional under +/// `--verifiable`; `--source-sha256`, when present, is validated as a pin in +/// `resolve_archive`. fn validate_source_formats(cmd: &Cmd) -> Result<(), Error> { if let Some(sha) = &cmd.source_sha256 { if !source_sha256_regex().is_match(sha) { @@ -359,7 +266,7 @@ fn validate_source_formats(cmd: &Cmd) -> Result<(), Error> { Ok(()) } -/// Outcome of `--archive`: the generated archive's SHA-256 and the directory it +/// Outcome of archiving: the generated archive's SHA-256 and the directory it /// was extracted into (held alive by `tmp`). struct ArchiveResult { source_sha256: String, @@ -367,10 +274,12 @@ struct ArchiveResult { tmp: tempfile::TempDir, } -/// Build the source archive, record its hash, write it out, and extract it into -/// a permission-hardened tempdir that the container then builds from. +/// Build the source archive, record its hash, write it to the managed archives +/// dir (content-addressed, so the bytes are available to upload for +/// `--source-uri`), and extract it into a permission-hardened tempdir that the +/// container then builds from. fn resolve_archive(cmd: &Cmd, source_root: &Path, print: &Print) -> Result { - let bytes = build_source_archive(source_root, print)?; + let bytes = source_archive::build_source_archive(source_root, print, true)?; let computed = hex::encode(Sha256::digest(&bytes)); // If the user pinned a hash, it must match what we produced. @@ -383,20 +292,15 @@ fn resolve_archive(cmd: &Cmd, source_root: &Path, print: &Print) -> Result p.clone(), - Some(None) => data::archives_dir()?.join(format!("{computed}.tar.gz")), - None => unreachable!("resolve_archive is only called when --archive is set"), - }; + // Content-addressed name under the managed archives dir. + let out_path = data::archives_dir()?.join(format!("{computed}.tar.gz")); if let Some(parent) = out_path.parent() { - std::fs::create_dir_all(parent).map_err(|source| Error::ArchiveWrite { + std::fs::create_dir_all(parent).map_err(|source| source_archive::Error::ArchiveWrite { path: out_path.clone(), source, })?; } - std::fs::write(&out_path, &bytes).map_err(|source| Error::ArchiveWrite { + std::fs::write(&out_path, &bytes).map_err(|source| source_archive::Error::ArchiveWrite { path: out_path.clone(), source, })?; @@ -413,16 +317,16 @@ fn resolve_archive(cmd: &Cmd, source_root: &Path, print: &Print) -> Result Result bool { - Command::new("git") - .arg("-C") - .arg(source_root) - .arg("rev-parse") - .arg("--is-inside-work-tree") - .output() - .is_ok_and(|o| o.status.success()) -} - -/// Produce the gzipped source tarball bytes. Entries are rooted under a -/// top-level `source/` prefix (so the archive extracts to a `source/` dir, -/// mirroring the container's `/source` mount). In a git repo this is `git -/// archive HEAD` (the committed tree); otherwise the working directory is -/// walked and tarred, skipping `ARCHIVE_DENYLIST` entries, after warning. -fn build_source_archive(source_root: &Path, print: &Print) -> Result, Error> { - let tar = if is_git_repo(source_root) { - git_archive_tar(source_root)? - } else { - print.warnln(format!( - "{} is not a git repository; archiving the working directory. Inspect the generated archive to confirm its contents.", - source_root.display(), - )); - walk_tar(source_root)? - }; - gzip(&tar) -} - -/// `git archive --format=tar --prefix=source/ HEAD`, returning the tar bytes. -fn git_archive_tar(source_root: &Path) -> Result, Error> { - let out = Command::new("git") - .arg("-C") - .arg(source_root) - .arg("archive") - .arg("--format=tar") - .arg("--prefix=source/") - .arg("HEAD") - .output() - .map_err(|source| Error::GitInvoke { - path: source_root.to_path_buf(), - source, - })?; - if !out.status.success() { - return Err(Error::GitArchive { - path: source_root.to_path_buf(), - stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(), - }); - } - Ok(out.stdout) -} - -/// Tar the working tree under `source_root`, skipping denylisted path -/// components. Each entry is prefixed with `source/`. -/// -/// The output is reproducible, following GNU tar's reproducibility guidance -/// () -/// with the portable equivalents available via the `tar` crate (the system -/// `tar` can't be relied on — macOS ships bsdtar, which lacks `--sort`, -/// `--mtime`, `--pax-option`, …): entries are sorted by name (`--sort=name`) -/// using locale-independent path ordering (`LC_ALL=C`), and `HeaderMode::Deterministic` -/// zeroes mtime (`--mtime`/`--clamp-mtime`), sets uid/gid to 0 with empty owner -/// names (`--owner=0 --group=0 --numeric-owner`), and normalizes mode -/// (`--mode=go+u,go-w`). ustar headers carry no atime/ctime or tar PID. The gzip -/// wrapper (see `gzip`) is likewise deterministic. -fn walk_tar(source_root: &Path) -> Result, Error> { - let mut files: Vec = Vec::new(); - let walk = WalkDir::new(source_root) - .sort_by_file_name() - .into_iter() - .filter_entry(|e| !is_denylisted(e.file_name())); - for entry in walk { - let entry = entry.map_err(|e| Error::ArchiveWrite { - path: source_root.to_path_buf(), - source: e.into(), - })?; - if entry.file_type().is_file() { - files.push(entry.path().to_path_buf()); - } - } - files.sort(); - - let mut builder = tar::Builder::new(Vec::new()); - builder.mode(tar::HeaderMode::Deterministic); - for path in &files { - let rel = path.strip_prefix(source_root).unwrap_or(path); - let name = Path::new("source").join(rel); - let mut f = std::fs::File::open(path).map_err(|source| Error::ArchiveWrite { - path: path.clone(), - source, - })?; - builder - .append_file(&name, &mut f) - .map_err(|source| Error::ArchiveWrite { - path: path.clone(), - source, - })?; - } - builder.into_inner().map_err(|source| Error::ArchiveWrite { - path: source_root.to_path_buf(), - source, - }) -} - -/// A path component is denylisted if it equals a denylist entry, or — for -/// dotted entries, which double as extension filters (e.g. `.swp`, `.log`) — if -/// it ends with that entry. Plain names (`target`, `node_modules`) match -/// exactly only, so `mytarget` is not excluded. -fn is_denylisted(name: &std::ffi::OsStr) -> bool { - let name = name.to_string_lossy(); - ARCHIVE_DENYLIST - .iter() - .any(|d| name == *d || (d.starts_with('.') && name.ends_with(d))) -} - -/// Gzip with a default (mtime-zeroed) header so the same tar bytes always hash -/// the same. -fn gzip(bytes: &[u8]) -> Result, Error> { - let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); - enc.write_all(bytes).map_err(|source| Error::ArchiveWrite { - path: PathBuf::new(), - source, - })?; - enc.finish().map_err(|source| Error::ArchiveWrite { - path: PathBuf::new(), - source, - }) -} - -/// Decompress gzip and unpack the tar into `dest`. Entries are `source/…`, so -/// they land at `/source/…`. -fn unpack_targz(bytes: &[u8], dest: &Path) -> Result<(), Error> { - let dec = flate2::read::GzDecoder::new(bytes); - tar::Archive::new(dec) - .unpack(dest) - .map_err(Error::ArchiveExtract) -} - /// Refuse to run a verifiable build against a dirty git working tree: the /// bind-mounted source must match the recorded source_sha256 for the build to /// be reproducible. When the source root isn't a git repo (e.g. an extracted /// archive) we can't check, so we proceed — the user owns the source_sha256 /// they pass, and verify catches a mismatch at byte-comparison time. fn enforce_clean_tree(source_root: &Path) -> Result<(), Error> { - let status = Command::new("git") - .arg("-C") - .arg(source_root) - .arg("status") - .arg("--porcelain") - .output() - .map_err(|e| Error::GitInvoke { - path: source_root.to_path_buf(), - source: e, - })?; - - // Not a git repo (or git refused): can't verify cleanliness, proceed. - if !status.status.success() { - return Ok(()); - } - - if !status.stdout.is_empty() { + if source_archive::tree_is_dirty(source_root)? { return Err(Error::GitDirty { path: source_root.to_path_buf(), }); } - Ok(()) } @@ -1388,117 +1137,6 @@ mod tests { validate_source_formats(&cmd).unwrap(); } - #[test] - fn is_denylisted_matches_names_and_dotted_suffixes() { - use std::ffi::OsStr; - // exact name matches - assert!(is_denylisted(OsStr::new("target"))); - assert!(is_denylisted(OsStr::new(".git"))); - assert!(is_denylisted(OsStr::new(".DS_Store"))); - // plain names match exactly only - assert!(!is_denylisted(OsStr::new("mytarget"))); - assert!(!is_denylisted(OsStr::new("targets"))); - // dotted entries also match as suffix (extension-style) - assert!(is_denylisted(OsStr::new("backup.git"))); - // unrelated files pass through - assert!(!is_denylisted(OsStr::new("Cargo.toml"))); - assert!(!is_denylisted(OsStr::new("lib.rs"))); - } - - // Initialize a git repo at `root` with one commit of everything present. - #[cfg(unix)] - fn git_init_commit(root: &Path) { - for args in [ - &["init", "-q", "-b", "main"][..], - &["add", "-A"][..], - &["commit", "-q", "-m", "init"][..], - ] { - let ok = Command::new("git") - .arg("-C") - .arg(root) - .args(args) - .env("GIT_AUTHOR_NAME", "T") - .env("GIT_AUTHOR_EMAIL", "t@e.x") - .env("GIT_COMMITTER_NAME", "T") - .env("GIT_COMMITTER_EMAIL", "t@e.x") - .status() - .unwrap() - .success(); - assert!(ok); - } - } - - #[test] - #[cfg(unix)] - fn build_source_archive_git_is_prefixed_and_deterministic() { - use std::os::unix::fs::PermissionsExt; - let print = Print::new(true); - let temp = tempfile::TempDir::new().unwrap(); - let root = temp.path(); - std::fs::write(root.join("Cargo.toml"), b"# crate").unwrap(); - std::fs::create_dir_all(root.join("src")).unwrap(); - std::fs::write(root.join("src/lib.rs"), b"// code").unwrap(); - git_init_commit(root); - - let a = build_source_archive(root, &print).unwrap(); - let b = build_source_archive(root, &print).unwrap(); - assert!(!a.is_empty()); - assert_eq!(a, b, "same commit should produce identical bytes"); - - let sha = hex::encode(Sha256::digest(&a)); - assert_eq!(sha.len(), 64); - - // Unpack and confirm the `source/` prefix + hardened perms. - let dest = tempfile::TempDir::new().unwrap(); - unpack_targz(&a, dest.path()).unwrap(); - assert!(dest.path().join("source/Cargo.toml").exists()); - assert!(dest.path().join("source/src/lib.rs").exists()); - - enforce_hardened_tree(dest.path()).unwrap(); - let file_mode = std::fs::metadata(dest.path().join("source/Cargo.toml")) - .unwrap() - .permissions() - .mode() - & 0o777; - let dir_mode = std::fs::metadata(dest.path().join("source")) - .unwrap() - .permissions() - .mode() - & 0o777; - assert_eq!(file_mode, 0o600); - assert_eq!(dir_mode, 0o700); - } - - #[test] - fn build_source_archive_non_git_excludes_denylist() { - let print = Print::new(true); - let temp = tempfile::TempDir::new().unwrap(); - let root = temp.path(); - std::fs::write(root.join("Cargo.toml"), b"# crate").unwrap(); - std::fs::create_dir_all(root.join("src")).unwrap(); - std::fs::write(root.join("src/lib.rs"), b"// code").unwrap(); - // Planted dirs that must be excluded. - std::fs::create_dir_all(root.join("target/debug")).unwrap(); - std::fs::write(root.join("target/debug/x"), b"junk").unwrap(); - std::fs::create_dir_all(root.join(".git")).unwrap(); - std::fs::write(root.join(".git/config"), b"junk").unwrap(); - - let bytes = build_source_archive(root, &print).unwrap(); - let dest = tempfile::TempDir::new().unwrap(); - unpack_targz(&bytes, dest.path()).unwrap(); - - assert!(dest.path().join("source/Cargo.toml").exists()); - assert!(dest.path().join("source/src/lib.rs").exists()); - assert!(!dest.path().join("source/target").exists()); - assert!(!dest.path().join("source/.git").exists()); - assert_eq!(hex::encode(Sha256::digest(&bytes)).len(), 64); - - // Reproducible: a second run over the same tree yields identical bytes - // (sorted entries + zeroed header fields + deterministic gzip). - let again = build_source_archive(root, &print).unwrap(); - assert_eq!(bytes, again); - } - #[test] fn bldimg_regex_accepts_docker_hub_full_ref() { assert!(bldimg_regex().is_match(&format!( @@ -1545,46 +1183,6 @@ mod tests { assert!(!source_uri_regex().is_match("https://has space")); // whitespace } - #[test] - fn resolve_source_root_finds_git_root_from_subdir() { - let temp = tempfile::TempDir::new().unwrap(); - let root = temp.path(); - std::fs::create_dir_all(root.join(".git")).unwrap(); - let nested = root.join("contracts").join("foo"); - std::fs::create_dir_all(&nested).unwrap(); - std::fs::write(nested.join("Cargo.toml"), b"# placeholder").unwrap(); - - let cmd = Cmd { - manifest_path: Some(nested.join("Cargo.toml")), - ..Cmd::default() - }; - // Use canonicalize on both sides — `tempfile` returns symlinked /var - // paths on macOS while resolve_source_root walks the same prefix. - let got = std::fs::canonicalize(resolve_source_root(&cmd)).unwrap(); - let want = std::fs::canonicalize(root).unwrap(); - assert_eq!(got, want); - } - - #[test] - fn resolve_source_root_falls_back_to_cwd_without_git() { - let temp = tempfile::TempDir::new().unwrap(); - let root = temp.path(); - let nested = root.join("noisy"); - std::fs::create_dir_all(&nested).unwrap(); - std::fs::write(nested.join("Cargo.toml"), b"# placeholder").unwrap(); - - let cmd = Cmd { - manifest_path: Some(nested.join("Cargo.toml")), - ..Cmd::default() - }; - // No `.git` anywhere up the tree, so we fall back to cwd. We can't - // assert what cwd is in a test runner (it varies), but we can assert - // that the returned path doesn't contain the manifest's parent and - // doesn't have `.git`. That's enough to confirm fallback kicked in. - let got = resolve_source_root(&cmd); - assert!(!got.join(".git").exists()); - } - #[test] fn compose_container_args_prefixes_subcommand() { let composed = compose_container_args( diff --git a/cmd/soroban-cli/src/commands/contract/fetch.rs b/cmd/soroban-cli/src/commands/contract/fetch.rs index a02bf98869..2905b8e375 100644 --- a/cmd/soroban-cli/src/commands/contract/fetch.rs +++ b/cmd/soroban-cli/src/commands/contract/fetch.rs @@ -22,14 +22,18 @@ pub struct Cmd { /// Contract ID to fetch #[arg(long = "id", env = "STELLAR_CONTRACT_ID")] pub contract_id: Option, + /// Wasm to fetch #[arg(long = "wasm-hash", conflicts_with = "contract_id")] pub wasm_hash: Option, + /// Where to write output otherwise stdout is used #[arg(long, short = 'o')] pub out_file: Option, + #[command(flatten)] pub locator: locator::Args, + #[command(flatten)] pub network: network::Args, } diff --git a/cmd/soroban-cli/src/commands/contract/mod.rs b/cmd/soroban-cli/src/commands/contract/mod.rs index fc4499c029..ee140be938 100644 --- a/cmd/soroban-cli/src/commands/contract/mod.rs +++ b/cmd/soroban-cli/src/commands/contract/mod.rs @@ -1,4 +1,5 @@ pub mod alias; +pub mod archive; pub mod arg_parsing; pub mod asset; pub mod bindings; @@ -33,6 +34,9 @@ pub enum Cmd { #[command(subcommand)] Bindings(bindings::Cmd), + /// Generate the reproducible source archive used by verifiable builds + Archive(archive::Cmd), + Build(build::Cmd), /// Extend the time to live ledger of a contract-data ledger entry. @@ -113,6 +117,9 @@ pub enum Error { #[error(transparent)] Bindings(#[from] bindings::Error), + #[error(transparent)] + Archive(#[from] archive::Error), + #[error(transparent)] Build(#[from] build::Error), @@ -163,6 +170,7 @@ impl Cmd { match &self { Cmd::Asset(asset) => asset.run(global_args).await?, Cmd::Bindings(bindings) => bindings.run().await?, + Cmd::Archive(archive) => archive.run(global_args)?, Cmd::Build(build) => { build.run(global_args).await?; } From 9892d6eea566883b5686919b14d490336dd42af1 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 17 Jun 2026 11:34:22 -0700 Subject: [PATCH 19/58] Add --env to set build environment variables. --- FULL_HELP_DOCS.md | 4 + cmd/crates/soroban-test/tests/it/build.rs | 41 ++++ .../src/commands/contract/build.rs | 95 +++++++++ .../src/commands/contract/build/verifiable.rs | 181 ++++++++++++++++-- 4 files changed, 304 insertions(+), 17 deletions(-) diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index d48ab89939..b5a68062d5 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -397,6 +397,7 @@ To view the commands that will be executed, without executing them, use the --pr If ommitted, wasm files are written only to the cargo target directory. - `--locked` — Assert that `Cargo.lock` will remain unchanged +- `--env ` — Set an environment variable for the build (repeatable), e.g. `--env NAME=VALUE`. It's set on the build process; for a verifiable build it's passed to the container and recorded as a `bldopt`, so avoid secrets there - `--optimize ` — Optimize the generated wasm. Enabled by default; pass `--optimize=false` to disable. Requires the `additional-libs` feature Default value: `true` @@ -502,6 +503,7 @@ Deploy a wasm contract Default value: `false` - `--alias ` — The alias that will be used to save the contract's id. Whenever used, `--alias` will always overwrite the existing contract id configuration without asking for confirmation +- `--env ` — Set an environment variable for the build (repeatable), e.g. `--env NAME=VALUE`. It's set on the build process; for a verifiable build it's passed to the container and recorded as a `bldopt`, so avoid secrets there - `--optimize ` — Optimize the generated wasm. Enabled by default; pass `--optimize=false` to disable. Requires the `additional-libs` feature Default value: `true` @@ -876,6 +878,7 @@ Install a WASM file to the ledger without creating a contract instance Default value: `false` +- `--env ` — Set an environment variable for the build (repeatable), e.g. `--env NAME=VALUE`. It's set on the build process; for a verifiable build it's passed to the container and recorded as a `bldopt`, so avoid secrets there - `--optimize ` — Optimize the generated wasm. Enabled by default; pass `--optimize=false` to disable. Requires the `additional-libs` feature Default value: `true` @@ -939,6 +942,7 @@ Install a WASM file to the ledger without creating a contract instance Default value: `false` +- `--env ` — Set an environment variable for the build (repeatable), e.g. `--env NAME=VALUE`. It's set on the build process; for a verifiable build it's passed to the container and recorded as a `bldopt`, so avoid secrets there - `--optimize ` — Optimize the generated wasm. Enabled by default; pass `--optimize=false` to disable. Requires the `additional-libs` feature Default value: `true` diff --git a/cmd/crates/soroban-test/tests/it/build.rs b/cmd/crates/soroban-test/tests/it/build.rs index dfe798e982..df4f395868 100644 --- a/cmd/crates/soroban-test/tests/it/build.rs +++ b/cmd/crates/soroban-test/tests/it/build.rs @@ -69,6 +69,47 @@ fn build_package_by_current_dir() { )); } +// `--env` is repeatable and sets env vars on the local cargo process; they +// surface in the printed command in --print-commands-only. +#[test] +fn build_with_env_vars() { + let sandbox = TestEnv::default(); + let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/workspace/contracts/add"); + sandbox + .new_assert_cmd("contract") + .current_dir(fixture_path) + .arg("build") + .arg("--print-commands-only") + .arg("--env") + .arg("FOO=bar") + .arg("--env") + .arg("BAZ=qux") + .assert() + .success() + .stdout(predicate::str::contains("FOO=bar").and(predicate::str::contains("BAZ=qux"))); +} + +// An invalid `--env` name is rejected before building. +#[test] +fn build_rejects_invalid_env_name() { + let sandbox = TestEnv::default(); + let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/workspace/contracts/add"); + sandbox + .new_assert_cmd("contract") + .current_dir(fixture_path) + .arg("build") + .arg("--print-commands-only") + .arg("--env") + .arg("1FOO=bar") + .assert() + .failure() + .stderr(predicate::str::contains( + "not a valid environment variable name", + )); +} + #[test] fn build_with_locked() { let sandbox = TestEnv::default(); diff --git a/cmd/soroban-cli/src/commands/contract/build.rs b/cmd/soroban-cli/src/commands/contract/build.rs index e8fb9c0c4b..56178b4742 100644 --- a/cmd/soroban-cli/src/commands/contract/build.rs +++ b/cmd/soroban-cli/src/commands/contract/build.rs @@ -146,6 +146,18 @@ pub struct BuildArgs { #[arg(long, num_args=1, value_parser=parse_meta_arg, action=clap::ArgAction::Append, help_heading = "Metadata")] pub meta: Vec<(String, String)>, + /// Set an environment variable for the build (repeatable), e.g. + /// `--env NAME=VALUE`. It's set on the build process; for a verifiable build + /// it's passed to the container and recorded as a `bldopt`, so avoid secrets + /// there. + #[arg( + long = "env", + num_args = 1, + value_parser = parse_env_arg, + action = clap::ArgAction::Append + )] + pub env: Vec<(String, String)>, + /// Optimize the generated wasm. Enabled by default; pass `--optimize=false` to disable. Requires the `additional-libs` feature. #[arg( long, @@ -163,6 +175,7 @@ impl Default for BuildArgs { fn default() -> Self { Self { meta: Vec::new(), + env: Vec::new(), optimize: true, } } @@ -179,6 +192,35 @@ pub fn parse_meta_arg(s: &str) -> Result<(String, String), Error> { Ok((key.to_string(), value.to_string())) } +/// Parse a `--env NAME=VALUE` argument. The name must be a valid environment +/// variable name (`[A-Za-z_][A-Za-z0-9_]*`, no surrounding whitespace); the +/// value is kept verbatim, since the shell has already resolved any quoting and +/// env values can carry significant whitespace. +pub fn parse_env_arg(s: &str) -> Result<(String, String), Error> { + let (name, value) = s + .split_once('=') + .ok_or_else(|| Error::EnvArg(format!("{s:?} must be in the form 'NAME=VALUE'")))?; + + if !is_valid_env_name(name) { + return Err(Error::EnvArg(format!( + "{name:?} is not a valid environment variable name (expected [A-Za-z_][A-Za-z0-9_]*)" + ))); + } + + Ok((name.to_string(), value.to_string())) +} + +/// Whether `name` is a valid environment variable name: a leading letter or +/// underscore followed by letters, digits, or underscores. +fn is_valid_env_name(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + #[derive(thiserror::Error, Debug)] pub enum Error { #[error(transparent)] @@ -220,6 +262,9 @@ pub enum Error { #[error("invalid meta entry: {0}")] MetaArg(String), + #[error("invalid env entry: {0}")] + EnvArg(String), + #[error( "use a rust version other than 1.81, 1.82, 1.83 or 1.91.0 to build contracts (got {0})" )] @@ -348,6 +393,11 @@ impl Cmd { // optimization using markers. cmd.env("SOROBAN_SDK_BUILD_SYSTEM_SUPPORTS_SPEC_SHAKING_V2", "1"); + // User-supplied build env vars (--env NAME=VALUE). + for (name, value) in &self.build_args.env { + cmd.env(name, value); + } + let cmd_str = serialize_command(&cmd); if self.print_commands_only { @@ -911,4 +961,49 @@ mod tests { "shlex round-trip failed: {raw_arg:?} not found as a single token in {tokens:?}" ); } + + #[test] + fn parse_env_arg_parses_name_value() { + assert_eq!( + parse_env_arg("FOO=bar").unwrap(), + ("FOO".to_string(), "bar".to_string()) + ); + assert_eq!( + parse_env_arg("_FOO_BAR2=bar").unwrap(), + ("_FOO_BAR2".to_string(), "bar".to_string()) + ); + // Only the first `=` splits; the value keeps the rest verbatim. + assert_eq!( + parse_env_arg("FOO=a=b=c").unwrap(), + ("FOO".to_string(), "a=b=c".to_string()) + ); + // An empty value is allowed. + assert_eq!( + parse_env_arg("FOO=").unwrap(), + ("FOO".to_string(), String::new()) + ); + // The value is kept verbatim (the shell already handled quoting), so + // significant whitespace survives. + assert_eq!( + parse_env_arg("FOO= 1 ").unwrap(), + ("FOO".to_string(), " 1 ".to_string()) + ); + } + + #[test] + fn parse_env_arg_rejects_invalid() { + for bad in [ + "FOO", // no `=` + "=bar", // empty name + " FOO = 1 ", // whitespace in name + "1FOO=x", // leading digit + "FO-O=x", // invalid char + "FOO BAR=x", // space in name + ] { + assert!( + matches!(parse_env_arg(bad).unwrap_err(), Error::EnvArg(_)), + "expected {bad:?} to be rejected" + ); + } + } } diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 75d89dad63..17326fa5f2 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -204,10 +204,18 @@ pub async fn run( // `--verbose` because verifications are run as part of pipelines. All // per-package builds run in one container so the crates download, compiled // deps, and target/ are shared. + let env: Vec = cmd + .build_args + .env + .iter() + .map(|(name, value)| format!("{name}={value}")) + .collect(); + run_in_container( &image_ref, &resolved.mount_root, &container_cmds, + &env, &docker, print, true, @@ -415,12 +423,26 @@ fn build_forwarded_args( let mut forwarded: Vec = Vec::new(); let mut bldopts: Vec = Vec::new(); - let mut record = |arg: String| { - forwarded.push(arg.clone()); - bldopts.push(arg); + // Record a build option. `None` means a bare flag (`--locked`); `Some(v)` + // means `--flag=v`. The forwarded copy keeps the value raw (the container + // gets it as argv, and `compose_shell_command` re-escapes it for the + // multi-package `sh -c`); the bldopt copy shell-escapes the value once, here + // at the source, so every recorded option is valid shell on its own and no + // consumer has to split a flag from its value later. For `key=value` + // payloads (`--meta`, `--env`) the key goes in `key` (`--meta=home_domain`) + // and only the value is escaped, keeping `--env=B='nice value'` rather than + // `'--env=B=nice value'`. + let mut record = |key: &str, value: Option<&str>| { + if let Some(v) = value { + forwarded.push(format!("{key}={v}")); + bldopts.push(format!("{key}={}", shell_escape::escape(v.into()))); + } else { + forwarded.push(key.to_string()); + bldopts.push(key.to_string()); + } }; - record("--locked".to_string()); + record("--locked", None); if let Some(path) = &cmd.manifest_path { let abs = std::path::absolute(path).unwrap_or_else(|_| path.clone()); @@ -428,30 +450,28 @@ fn build_forwarded_args( .strip_prefix(workspace_root) .map(Path::to_path_buf) .unwrap_or(abs); - record(format!("--manifest-path={}", rel.display())); + record("--manifest-path", Some(rel.display().to_string().as_str())); } if cmd.profile != "release" { - record(format!("--profile={}", cmd.profile)); + record("--profile", Some(cmd.profile.as_str())); } if let Some(features) = &cmd.features { - record(format!("--features={features}")); + record("--features", Some(features.as_str())); } if cmd.all_features { - record("--all-features".to_string()); + record("--all-features", None); } if cmd.no_default_features { - record("--no-default-features".to_string()); + record("--no-default-features", None); } // Always pin the package when it can be resolved (explicit `--package`, or // a workspace that builds exactly one cdylib by default) so the recorded // bldopt stays reproducible even if workspace default members change later. if let Some(pkg) = package { - record(format!("--package={pkg}")); + record("--package", Some(pkg)); } for (k, v) in &cmd.build_args.meta { - // Use the `--meta=key=value` form so each option is a single token, - // matching how clap re-parses on the container side. - record(format!("--meta={k}={v}")); + record(&format!("--meta={k}"), Some(v.as_str())); } // `--optimize` true is recorded as a bare flag (universally accepted). @@ -459,9 +479,21 @@ fn build_forwarded_args( // (added in `b17d3f0b`); on older containers, false is the default and // we record/forward nothing — passing `--optimize=false` there would fail. if cmd.build_args.optimize { - record("--optimize".to_string()); + record("--optimize", None); } else if supports_explicit_optimize_false { - record("--optimize=false".to_string()); + record("--optimize", Some("false")); + } + + // Build env vars are applied via docker `-e` (see run_in_container), not as + // arguments to the inner `stellar contract build`, so they're recorded as + // bldopts only — never forwarded. A verifier replays them with `--env`. The + // value is escaped (the name is a validated identifier) so the recorded + // option stays valid shell. + for (name, value) in &cmd.build_args.env { + bldopts.push(format!( + "--env={name}={}", + shell_escape::escape(value.as_str().into()) + )); } (forwarded, bldopts) @@ -484,6 +516,10 @@ fn build_metadata_args(image_ref: &str, ids: &SourceIds, bldopts: &[String]) -> push(&mut out, "source_sha256", v); } + // bldopts already arrive as valid shell (escaped at the source in + // `build_forwarded_args`), so they're recorded verbatim: a verifier + // reconstructs the build by joining the recorded values and running them + // through a shell. for o in bldopts { push(&mut out, "bldopt", o); } @@ -758,23 +794,44 @@ fn compose_shell_command(cmds: &[Vec]) -> String { .join(" && ") } +/// Shell-escape each token of a single-package container command so a value +/// with spaces (a `--meta` value, or an `--env=` recorded as a `bldopt`) +/// survives when the reproduce line is copy-pasted into a shell. The +/// single-package path runs the image's default `stellar` entrypoint directly, +/// so there's no `sh -c` wrapper as in `compose_shell_command`. +fn escape_container_args(cmd: &[String]) -> String { + cmd.iter() + .map(|tok| shell_escape::escape(tok.into()).into_owned()) + .collect::>() + .join(" ") +} + async fn run_in_container( image_ref: &str, workspace_root: &Path, container_cmds: &[Vec], + env: &[String], docker: &Docker, print: &Print, verbose: bool, ) -> Result<(), Error> { let bind = format!("{}:/source", workspace_root.display()); + // `-e KEY=VALUE` flags for the reproduce command, mirroring the env passed + // to the container below. + let mut env_flags = String::new(); + for e in env { + env_flags.push_str(" -e "); + env_flags.push_str(&shell_escape::escape(e.as_str().into())); + } + // One package → run the image's default `stellar` entrypoint directly. // Several → override the entrypoint to a shell and chain the builds so they // all run in this one container. let (entrypoint, cmd, reproduce) = if container_cmds.len() > 1 { let chain = compose_shell_command(container_cmds); let reproduce = format!( - "docker run --rm -v {bind} --entrypoint /bin/sh {image_ref} -c {}", + "docker run --rm -v {bind}{env_flags} --entrypoint /bin/sh {image_ref} -c {}", shell_escape::escape(chain.clone().into()) ); ( @@ -784,7 +841,10 @@ async fn run_in_container( ) } else { let cmd = container_cmds.first().cloned().unwrap_or_default(); - let reproduce = format!("docker run --rm -v {bind} {image_ref} {}", cmd.join(" ")); + let reproduce = format!( + "docker run --rm -v {bind}{env_flags} {image_ref} {}", + escape_container_args(&cmd) + ); (None, cmd, reproduce) }; @@ -792,6 +852,7 @@ async fn run_in_container( image: Some(image_ref.to_string()), entrypoint, cmd: Some(cmd), + env: (!env.is_empty()).then(|| env.to_vec()), working_dir: Some("/source".to_string()), attach_stdout: Some(true), attach_stderr: Some(true), @@ -806,6 +867,9 @@ async fn run_in_container( print.infoln(format!( "Running verifiable build in {image_ref} (mount {bind})" )); + if verbose { + print.infoln(format!("Running: {reproduce}")); + } let created = docker .create_container(None::, config) @@ -1015,6 +1079,7 @@ mod tests { ("home_domain".to_string(), "fnando.com".to_string()), ("author".to_string(), "alice".to_string()), ], + env: vec![], optimize: true, }, ..Cmd::default() @@ -1028,11 +1093,32 @@ mod tests { assert!(bldopts.contains(&"--manifest-path=contracts/add/Cargo.toml".to_string())); } + #[test] + fn build_forwarded_args_records_env_as_bldopt_only() { + let cmd = Cmd { + build_args: super::super::BuildArgs { + env: vec![ + ("FOO".to_string(), "bar".to_string()), + ("BAZ".to_string(), "qux".to_string()), + ], + ..super::super::BuildArgs::default() + }, + ..Cmd::default() + }; + let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), true); + // Env vars are applied via docker `-e`, so they're recorded as bldopts + // for the verifier but never forwarded as build arguments. + assert!(bldopts.contains(&"--env=FOO=bar".to_string())); + assert!(bldopts.contains(&"--env=BAZ=qux".to_string())); + assert!(!forwarded.iter().any(|a| a.starts_with("--env"))); + } + #[test] fn build_forwarded_args_optimize_false_new_container() { let cmd = Cmd { build_args: super::super::BuildArgs { meta: vec![], + env: vec![], optimize: false, }, ..Cmd::default() @@ -1047,6 +1133,7 @@ mod tests { let cmd = Cmd { build_args: super::super::BuildArgs { meta: vec![], + env: vec![], optimize: false, }, ..Cmd::default() @@ -1091,6 +1178,39 @@ mod tests { assert_eq!(p[4], ("--meta", "bldopt=--features=a")); } + #[test] + fn build_forwarded_args_escapes_bldopt_values_as_shell() { + // Values with shell metacharacters are escaped at the source so each + // recorded bldopt is valid shell on its own. Only the value side is + // quoted: `--env=B='this is very nice'`, never `'--env=B=this is very + // nice'` (which would quote the flag and key too). + let cmd = Cmd { + features: Some("a,b".to_string()), + build_args: super::super::BuildArgs { + meta: vec![("note".to_string(), "added on build".to_string())], + env: vec![ + ("B".to_string(), "this is very nice".to_string()), + ("C".to_string(), "it's a \"trap\"".to_string()), + ], + optimize: true, + }, + ..Cmd::default() + }; + let (_forwarded, bldopts) = build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), true); + + // The flag and key stay outside the quotes; only the value is escaped. + assert!(bldopts.contains(&"--env=B='this is very nice'".to_string())); + assert!(bldopts.contains(&"--meta=note='added on build'".to_string())); + // No-metacharacter values stay verbatim. + assert!(bldopts.contains(&"--features=a,b".to_string())); + + // Every recorded bldopt is valid shell that parses back to one argv token. + for o in &bldopts { + let tokens = shlex::split(o).expect("each bldopt must be valid shell"); + assert_eq!(tokens.len(), 1, "{o} must be a single shell token"); + } + } + #[test] fn build_metadata_args_sha256_only_omits_uri() { let ids = SourceIds { @@ -1235,4 +1355,31 @@ mod tests { "expected the spaced value to be quoted, got: {s}" ); } + + #[test] + fn escape_container_args_quotes_spaced_tokens() { + // An `--env=` recorded as a bldopt carries the env value verbatim, so a + // spaced value lands in a single `--meta bldopt=…` token. The reproduce + // line must quote it so a copy-paste round-trips back to one argv token. + let cmd = vec![ + "contract".to_string(), + "build".to_string(), + "--package=hello-world".to_string(), + "--meta".to_string(), + "bldopt=--env=B=this is very nice".to_string(), + ]; + let s = escape_container_args(&cmd); + let tokens = shlex::split(&s).expect("reproduce args must be valid shell"); + assert_eq!( + tokens, + vec![ + "contract", + "build", + "--package=hello-world", + "--meta", + "bldopt=--env=B=this is very nice", + ], + "spaced token must survive a shlex round-trip as one argument" + ); + } } From 3af186c0ffaee1b59e332b0c541fdf5cd688b842 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 18 Jun 2026 16:44:45 -0700 Subject: [PATCH 20/58] Pin the rust toolchain in verifiable builds. --- .../src/commands/contract/build/verifiable.rs | 106 +++++++++++++++--- 1 file changed, 88 insertions(+), 18 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 17326fa5f2..1c6c15ba0a 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -777,6 +777,61 @@ async fn probe_cli_version(image_ref: &str, docker: &Docker) -> Result Option { + let config = ContainerCreateBody { + image: Some(image_ref.to_string()), + entrypoint: Some(vec!["rustup".to_string()]), + cmd: Some(vec!["show".to_string(), "active-toolchain".to_string()]), + attach_stdout: Some(true), + attach_stderr: Some(true), + host_config: Some(HostConfig { + auto_remove: Some(true), + ..Default::default() + }), + ..Default::default() + }; + let created = docker + .create_container(None::, config) + .await + .ok()?; + let attached = docker + .attach_container( + &created.id, + Some(AttachContainerOptions { + stdout: true, + stderr: true, + stream: true, + ..Default::default() + }), + ) + .await + .ok()?; + docker + .start_container(&created.id, None::) + .await + .ok()?; + + let mut stdout = String::new(); + let mut output = attached.output; + while let Some(chunk) = output.next().await { + if let Ok(bollard::container::LogOutput::StdOut { message }) = chunk { + stdout.push_str(&String::from_utf8_lossy(&message)); + } + } + + let mut wait = docker.wait_container(&created.id, None::); + while wait.next().await.is_some() {} + + stdout.split_whitespace().next().map(str::to_string) +} + /// Render the per-package `stellar contract build …` commands into a single /// `sh -c` script (`stellar … && stellar …`), shell-escaping every token so meta /// values with spaces survive. Used when more than one package is built so they @@ -817,10 +872,22 @@ async fn run_in_container( ) -> Result<(), Error> { let bind = format!("{}:/source", workspace_root.display()); + // Pin rustup to the image's own toolchain (per SEP-58): without this, a + // `rust-toolchain.toml` in the source could make rustup switch toolchains + // mid-build, defeating the digest-pinned image. Probe the image for its + // active toolchain and pass it through with `-e`, unless the caller already + // set RUSTUP_TOOLCHAIN. Skipped silently when the image has no rustup. + let mut env = env.to_vec(); + if !env.iter().any(|e| e.starts_with("RUSTUP_TOOLCHAIN=")) { + if let Some(toolchain) = probe_active_toolchain(image_ref, docker).await { + env.push(format!("RUSTUP_TOOLCHAIN={toolchain}")); + } + } + // `-e KEY=VALUE` flags for the reproduce command, mirroring the env passed // to the container below. let mut env_flags = String::new(); - for e in env { + for e in &env { env_flags.push_str(" -e "); env_flags.push_str(&shell_escape::escape(e.as_str().into())); } @@ -852,7 +919,7 @@ async fn run_in_container( image: Some(image_ref.to_string()), entrypoint, cmd: Some(cmd), - env: (!env.is_empty()).then(|| env.to_vec()), + env: (!env.is_empty()).then(|| env.clone()), working_dir: Some("/source".to_string()), attach_stdout: Some(true), attach_stderr: Some(true), @@ -908,24 +975,27 @@ async fn run_in_container( } } - let mut wait = docker.wait_container(&created.id, None::); + wait_for_container_exit(docker, &created.id, &reproduce).await +} + +/// Block until the container exits, mapping a non-zero exit code (whether +/// reported as a successful wait or as a `DockerContainerWaitError`) to +/// `ContainerExit` carrying the reproduce command. +async fn wait_for_container_exit(docker: &Docker, id: &str, reproduce: &str) -> Result<(), Error> { + let mut wait = docker.wait_container(id, None::); while let Some(item) = wait.next().await { - match item { - Ok(r) if r.status_code == 0 => {} - Ok(r) => { - return Err(Error::ContainerExit { - status: r.status_code, - command: reproduce.clone(), - }); - } - Err(bollard::errors::Error::DockerContainerWaitError { code: 0, .. }) => {} - Err(bollard::errors::Error::DockerContainerWaitError { code, .. }) => { - return Err(Error::ContainerExit { - status: code, - command: reproduce.clone(), - }); - } + // Both a successful wait and a `DockerContainerWaitError` carry an exit + // code; normalize to it (other errors are genuine failures). + let status = match item { + Ok(r) => r.status_code, + Err(bollard::errors::Error::DockerContainerWaitError { code, .. }) => code, Err(e) => return Err(e.into()), + }; + if status != 0 { + return Err(Error::ContainerExit { + status, + command: reproduce.to_string(), + }); } } From bf7cf3abdf427914f6c9aa8dd57ba25673be14d9 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 19 Jun 2026 12:14:08 -0700 Subject: [PATCH 21/58] Build source archives from the working directory. --- FULL_HELP_DOCS.md | 1 - cmd/crates/soroban-test/tests/it/build.rs | 30 ++ cmd/soroban-cli/Cargo.toml | 2 +- .../src/commands/contract/archive.rs | 29 +- .../commands/contract/build/source_archive.rs | 331 ++++++++++-------- .../src/commands/contract/build/verifiable.rs | 40 +-- 6 files changed, 228 insertions(+), 205 deletions(-) diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index b5a68062d5..4d10b321b9 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -354,7 +354,6 @@ Generate the reproducible source archive used by verifiable builds ###### **Options:** - `-o`, `--out-file ` — Where to write the gzipped tarball. Required unless `--dry-run` is used -- `--manifest-path ` — Path to Cargo.toml, used to locate the source root (its enclosing git repository, or the working directory) - `--dry-run` — List the entries that would be archived and the computed source_sha256, without writing any file ## `stellar contract build` diff --git a/cmd/crates/soroban-test/tests/it/build.rs b/cmd/crates/soroban-test/tests/it/build.rs index df4f395868..6d1ba0290c 100644 --- a/cmd/crates/soroban-test/tests/it/build.rs +++ b/cmd/crates/soroban-test/tests/it/build.rs @@ -1254,6 +1254,36 @@ fn contract_archive_requires_out_file_without_dry_run() { .stderr(predicate::str::contains("--out-file")); } +// A dirty git tree is a hard fail for `contract archive` too, matching +// `--verifiable`: the source_sha256 must describe a committed state. +#[test] +fn contract_archive_dirty_tree_errors() { + 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"]); + // Dirty the tree after committing so status is non-empty. + std::fs::write(workspace.join("dirty.txt"), b"uncommitted").unwrap(); + + let out = temp.path().join("src.tar.gz"); + + sandbox + .new_assert_cmd("contract") + .current_dir(&workspace) + .arg("archive") + .arg("--out-file") + .arg(&out) + .assert() + .failure() + .stderr(predicate::str::contains("dirty")); + + assert!( + !out.exists(), + "no archive should be written for a dirty tree" + ); +} + // `--source-sha256` value must match the 64-hex regex. #[test] fn verifiable_source_sha256_format_errors() { diff --git a/cmd/soroban-cli/Cargo.toml b/cmd/soroban-cli/Cargo.toml index a1d29e654f..9cf393047c 100644 --- a/cmd/soroban-cli/Cargo.toml +++ b/cmd/soroban-cli/Cargo.toml @@ -129,7 +129,7 @@ whoami = "1.5.2" serde_with = "3.11.0" rustc_version = "0.4.1" tar = "0.4.40" -walkdir = "2.5.0" +ignore = "0.4.26" [build-dependencies] crate-git-revision = "0.0.9" diff --git a/cmd/soroban-cli/src/commands/contract/archive.rs b/cmd/soroban-cli/src/commands/contract/archive.rs index d4fe4cef03..34248fb5f1 100644 --- a/cmd/soroban-cli/src/commands/contract/archive.rs +++ b/cmd/soroban-cli/src/commands/contract/archive.rs @@ -19,9 +19,9 @@ const ARCHIVE_EXTENSIONS: &[&str] = &[".tar.gz", ".tgz"]; /// handy for confirming the contents before a verifiable build, or for /// producing the archive to host at a `--source-uri`. /// -/// In a git repo the archive is `git archive HEAD` (the committed tree); -/// otherwise the working directory is archived minus a built-in denylist (.git, -/// target/, node_modules/, .DS_Store, …). +/// The archive is the current working directory, honoring the project's +/// `.gitignore` and `.ignore` files (the `.git` directory itself is always +/// skipped). Run this from the project (or workspace) root you want archived. #[derive(Parser, Debug, Clone)] #[group(skip)] pub struct Cmd { @@ -29,11 +29,6 @@ pub struct Cmd { #[arg(long, short = 'o', required_unless_present = "dry_run")] pub out_file: Option, - /// Path to Cargo.toml, used to locate the source root (its enclosing git - /// repository, or the working directory). - #[arg(long)] - pub manifest_path: Option, - /// List the entries that would be archived and the computed source_sha256, /// without writing any file. #[arg(long)] @@ -55,18 +50,12 @@ impl Cmd { pub fn run(&self, global_args: &global::Args) -> Result<(), Error> { let print = Print::new(global_args.quiet); - let source_root = source_archive::resolve_source_root(self.manifest_path.as_deref()); - - // The git path archives HEAD, so uncommitted changes are silently - // excluded. Warn (don't fail — this is an inspect/generate tool, not a - // build) so the printed source_sha256 isn't mistaken for the working - // tree's. - if source_archive::tree_is_dirty(&source_root)? { - print.warnln(format!( - "git working tree at {} is dirty; the archive reflects HEAD only and excludes uncommitted changes.", - source_root.display(), - )); - } + let source_root = source_archive::resolve_source_root(); + + // The archive is the working tree, so a dirty repo would bake uncommitted + // changes into the bytes and the printed source_sha256 — refuse it, so the + // hash always corresponds to a committed state (matching --verifiable). + source_archive::ensure_clean_tree(&source_root, &print)?; // The dry-run listing itself reveals the contents, so skip the // "not a git repository" warning there. 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 2f20d516cd..b266cc81c4 100644 --- a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs +++ b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs @@ -2,10 +2,10 @@ //! //! Produces a gzipped tarball of a contract's source tree, rooted under a //! top-level `source/` prefix (so it extracts to a `source/` dir, mirroring the -//! container's `/source` mount). In a git repo this is `git archive HEAD` (the -//! committed tree); otherwise the working directory is walked and tarred, -//! skipping `ARCHIVE_DENYLIST` entries. The output is byte-reproducible, so the -//! same tree always hashes to the same `source_sha256`. +//! container's `/source` mount). The working directory is walked and tarred, +//! honoring the project's own `.gitignore`/`.ignore` files (the `.git` directory +//! itself is always skipped). The output is byte-reproducible, so the same tree +//! always hashes to the same `source_sha256`. //! //! Shared by `contract build --verifiable` (which builds from the extracted //! archive) and the standalone `contract archive` command (which generates and @@ -17,19 +17,18 @@ use std::{ process::Command, }; -use walkdir::WalkDir; +use ignore::WalkBuilder; use crate::print::Print; -/// Top-level names excluded when archiving a non-git working directory (we have -/// no tracked-files list to consult, so fall back to a fixed denylist of VCS -/// metadata, build/cache/transient dirs, and editor/OS/AI-assistant junk). -/// Matched against each path component, so a directory like `target/` prunes -/// its whole subtree. -pub(crate) const ARCHIVE_DENYLIST: &[&str] = &[ - // version control - ".git", - ".gitignore", +/// Names that usually shouldn't end up in a source archive — VCS metadata of +/// other systems, secrets/local env, build/cache/transient dirs, and editor/OS/ +/// AI-assistant junk. These don't *exclude* anything (selection is driven +/// entirely by `.gitignore`/`.ignore`); instead, if any of them slip into the +/// archive because the project didn't ignore them, we warn the user so they can +/// add an ignore rule. Matched against each path component. +pub(crate) const ARCHIVE_WARN_LIST: &[&str] = &[ + // version control (other systems) ".svn", ".hg", // secrets / local environment @@ -62,8 +61,10 @@ pub enum Error { source: std::io::Error, }, - #[error("`git archive` failed in {path}: {stderr}")] - GitArchive { path: PathBuf, stderr: String }, + #[error( + "refusing to archive a dirty git working tree at {path}; commit or stash your changes and try again." + )] + GitDirty { path: PathBuf }, #[error("could not write source archive to {path}: {source}")] ArchiveWrite { @@ -75,50 +76,41 @@ pub enum Error { ArchiveExtract(std::io::Error), } -/// Pick the anchor for the source tree: the directory whose `.git` parent we -/// archive (and, for verifiable builds, relativize `--manifest-path` against). -/// Walk up from `manifest_path` (or cwd, if none) looking for a `.git` -/// directory; return its parent. If none is found, fall back to cwd. -/// -/// This isn't a validation step — any `.git` will do. Wrong-source mistakes are -/// caught later by the verify-side byte comparison. -pub(crate) fn resolve_source_root(manifest_path: Option<&Path>) -> PathBuf { - let start = if let Some(p) = manifest_path { - let abs = std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()); - abs.parent().map(Path::to_path_buf).unwrap_or(abs) - } else { - std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) - }; - - let mut p = start.clone(); - loop { - if p.join(".git").exists() { - return p; - } - if !p.pop() { - break; - } - } - - std::env::current_dir().unwrap_or(start) +/// The source tree's root: always the current working directory. The archive is +/// rooted there as-is — we do NOT search upward for a git repository or anchor on +/// `--manifest-path`'s directory, since for a workspace member the build needs +/// the whole workspace (its root `Cargo.toml`/`Cargo.lock`), which lives at the +/// cwd, not the member's directory. So run `contract archive`/`build +/// --verifiable` from the project (or workspace) root you want archived; +/// `--manifest-path`, when given, is interpreted relative to it. +pub(crate) fn resolve_source_root() -> PathBuf { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) } -/// Whether `source_root` is inside a git work tree. -pub(crate) fn is_git_repo(source_root: &Path) -> bool { - Command::new("git") - .arg("-C") - .arg(source_root) - .arg("rev-parse") - .arg("--is-inside-work-tree") - .output() - .is_ok_and(|o| o.status.success()) +/// Warn about and reject a dirty git working tree. Both `contract archive` and +/// `build --verifiable` archive the working tree as-is, so uncommitted changes +/// would be baked into the recorded `source_sha256`; refuse them (after +/// explaining why) so an archive always corresponds to a committed state. A +/// no-op when `source_root` isn't a git repo (we can't check, e.g. archive +/// sources) — the user owns the bytes they produce there. +pub(crate) fn ensure_clean_tree(source_root: &Path, print: &Print) -> Result<(), Error> { + if tree_is_dirty(source_root)? { + print.warnln(format!( + "git working tree at {} is dirty; the archive would include uncommitted changes.", + source_root.display(), + )); + return Err(Error::GitDirty { + path: source_root.to_path_buf(), + }); + } + Ok(()) } /// Whether `source_root` is a git work tree with uncommitted changes. Returns /// `Ok(false)` when it isn't a git repo (git ran but refused) — callers can't /// verify cleanliness there, so they proceed. Errors only when git can't be /// invoked at all. -pub(crate) fn tree_is_dirty(source_root: &Path) -> Result { +fn tree_is_dirty(source_root: &Path) -> Result { let status = Command::new("git") .arg("-C") .arg(source_root) @@ -138,31 +130,20 @@ pub(crate) fn tree_is_dirty(source_root: &Path) -> Result { Ok(!status.stdout.is_empty()) } -/// Produce the gzipped source tarball bytes. Entries are rooted under a -/// top-level `source/` prefix. In a git repo this is `git archive HEAD` (the -/// committed tree); otherwise the working directory is walked and tarred, -/// skipping `ARCHIVE_DENYLIST` entries. +/// Produce the gzipped source tarball bytes. The working directory under +/// `source_root` is walked and tarred, honoring the project's `.gitignore`/ +/// `.ignore` files; entries are rooted under a top-level `source/` prefix. /// -/// When the source isn't a git repo, `warn_non_git` controls whether to warn -/// that the working directory is being archived. Callers that only inspect the -/// result (e.g. `contract archive --dry-run`) pass `false`, since the listing -/// itself reveals the contents. +/// `warn` controls whether to warn about archived paths that usually shouldn't +/// be shipped (see `ARCHIVE_WARN_LIST`). Callers that only inspect the result +/// (e.g. `contract archive --dry-run`) pass `false`, since the listing itself +/// reveals the contents. pub(crate) fn build_source_archive( source_root: &Path, print: &Print, - warn_non_git: bool, + warn: bool, ) -> Result, Error> { - let tar = if is_git_repo(source_root) { - git_archive_tar(source_root)? - } else { - if warn_non_git { - print.warnln(format!( - "{} is not a git repository; archiving the working directory. Inspect the generated archive to confirm its contents.", - source_root.display(), - )); - } - walk_tar(source_root)? - }; + let tar = walk_tar(source_root, print, warn)?; gzip(&tar) } @@ -181,31 +162,16 @@ pub(crate) fn entry_names(bytes: &[u8]) -> Result, Error> { Ok(names) } -/// `git archive --format=tar --prefix=source/ HEAD`, returning the tar bytes. -fn git_archive_tar(source_root: &Path) -> Result, Error> { - let out = Command::new("git") - .arg("-C") - .arg(source_root) - .arg("archive") - .arg("--format=tar") - .arg("--prefix=source/") - .arg("HEAD") - .output() - .map_err(|source| Error::GitInvoke { - path: source_root.to_path_buf(), - source, - })?; - if !out.status.success() { - return Err(Error::GitArchive { - path: source_root.to_path_buf(), - stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(), - }); - } - Ok(out.stdout) -} - -/// Tar the working tree under `source_root`, skipping denylisted path -/// components. Each entry is prefixed with `source/`. +/// Tar the working tree under `source_root`, honoring the project's `.gitignore`/ +/// `.ignore` files and always skipping the `.git` directory. Each entry is +/// prefixed with `source/`. When `warn` is set, archived paths matching +/// `ARCHIVE_WARN_LIST` (e.g. `.env`, `target/`) trigger a warning so the user can +/// add an ignore rule. +/// +/// Selection depends only on the in-tree files plus the `.gitignore`/`.ignore` +/// files inside the archived tree — never on machine-specific state (the global +/// gitignore, `.git/info/exclude`, or ignore files in parent directories are not +/// consulted) — so the archive stays byte-reproducible across machines. /// /// The output is reproducible, following GNU tar's reproducibility guidance /// () @@ -217,23 +183,34 @@ fn git_archive_tar(source_root: &Path) -> Result, Error> { /// names (`--owner=0 --group=0 --numeric-owner`), and normalizes mode /// (`--mode=go+u,go-w`). ustar headers carry no atime/ctime or tar PID. The gzip /// wrapper (see `gzip`) is likewise deterministic. -fn walk_tar(source_root: &Path) -> Result, Error> { +fn walk_tar(source_root: &Path, print: &Print, warn: bool) -> Result, Error> { + let walk = WalkBuilder::new(source_root) + .hidden(false) // include dotfiles; let .gitignore decide + .git_ignore(true) // honor in-tree .gitignore + .ignore(true) // honor .ignore + .git_global(false) // not the machine's global gitignore (not reproducible) + .git_exclude(false) // not .git/info/exclude (not in the archive) + .require_git(false) // apply .gitignore/.ignore even without a .git dir + .parents(false) // only ignore files inside the archived tree + .filter_entry(|e| e.file_name() != ".git") // never archive VCS internals + .build(); + let mut files: Vec = Vec::new(); - let walk = WalkDir::new(source_root) - .sort_by_file_name() - .into_iter() - .filter_entry(|e| !is_denylisted(e.file_name())); for entry in walk { - let entry = entry.map_err(|e| Error::ArchiveWrite { + let entry = entry.map_err(|source| Error::ArchiveWrite { path: source_root.to_path_buf(), - source: e.into(), + source: std::io::Error::other(source), })?; - if entry.file_type().is_file() { + if entry.file_type().is_some_and(|t| t.is_file()) { files.push(entry.path().to_path_buf()); } } files.sort(); + if warn { + warn_unexpected_paths(&files, source_root, print); + } + let mut builder = tar::Builder::new(Vec::new()); builder.mode(tar::HeaderMode::Deterministic); for path in &files { @@ -256,17 +233,51 @@ fn walk_tar(source_root: &Path) -> Result, Error> { }) } -/// A path component is denylisted if it equals a denylist entry, or — for -/// dotted entries, which double as extension filters (e.g. `.swp`, `.log`) — if -/// it ends with that entry. Plain names (`target`, `node_modules`) match -/// exactly only, so `mytarget` is not excluded. -fn is_denylisted(name: &std::ffi::OsStr) -> bool { +/// Whether a path component matches the warn list: it equals an entry, or — for +/// dotted entries, which double as extension filters (e.g. `.swp`, `.log`) — it +/// ends with that entry. Plain names (`target`, `node_modules`) match exactly +/// only, so `mytarget` is not flagged. +fn is_warned(name: &std::ffi::OsStr) -> bool { let name = name.to_string_lossy(); - ARCHIVE_DENYLIST + ARCHIVE_WARN_LIST .iter() .any(|d| name == *d || (d.starts_with('.') && name.ends_with(d))) } +/// Warn about archived paths that usually shouldn't be shipped (secrets, build +/// output, editor/OS junk; see `ARCHIVE_WARN_LIST`). Selection is driven by +/// `.gitignore`/`.ignore`, so these slipped in only because the project didn't +/// ignore them — point that out so the user can add a rule. Reports the path up +/// to each matched component once (so a flagged directory is named once, not per +/// file under it), each on its own line since paths can be long. +fn warn_unexpected_paths(files: &[PathBuf], source_root: &Path, print: &Print) { + let mut hits: Vec = Vec::new(); + for path in files { + let rel = path.strip_prefix(source_root).unwrap_or(path); + let mut prefix = PathBuf::new(); + for comp in rel.components() { + prefix.push(comp); + if is_warned(comp.as_os_str()) { + let hit = prefix.to_string_lossy().into_owned(); + if !hits.contains(&hit) { + hits.push(hit); + } + break; + } + } + } + if hits.is_empty() { + return; + } + hits.sort(); + print.warnln( + "archive includes paths usually excluded; add them to .gitignore or .ignore if unintended:", + ); + for hit in &hits { + print.blankln(hit); + } +} + /// Gzip with a default (mtime-zeroed) header so the same tar bytes always hash /// the same. fn gzip(bytes: &[u8]) -> Result, Error> { @@ -297,22 +308,24 @@ mod tests { use sha2::{Digest, Sha256}; #[test] - fn is_denylisted_matches_names_and_dotted_suffixes() { + fn is_warned_matches_names_and_dotted_suffixes() { use std::ffi::OsStr; // exact name matches - assert!(is_denylisted(OsStr::new("target"))); - assert!(is_denylisted(OsStr::new(".git"))); - assert!(is_denylisted(OsStr::new(".gitignore"))); - assert!(is_denylisted(OsStr::new(".env"))); - assert!(is_denylisted(OsStr::new(".DS_Store"))); + assert!(is_warned(OsStr::new("target"))); + assert!(is_warned(OsStr::new(".env"))); + assert!(is_warned(OsStr::new(".DS_Store"))); // plain names match exactly only - assert!(!is_denylisted(OsStr::new("mytarget"))); - assert!(!is_denylisted(OsStr::new("targets"))); + assert!(!is_warned(OsStr::new("mytarget"))); + assert!(!is_warned(OsStr::new("targets"))); // dotted entries also match as suffix (extension-style) - assert!(is_denylisted(OsStr::new("backup.git"))); + assert!(is_warned(OsStr::new("backup.svn"))); + // `.git`/`.gitignore` are not warned: `.git` is skipped structurally and + // `.gitignore` is legitimately archived like any other tracked file. + assert!(!is_warned(OsStr::new(".git"))); + assert!(!is_warned(OsStr::new(".gitignore"))); // unrelated files pass through - assert!(!is_denylisted(OsStr::new("Cargo.toml"))); - assert!(!is_denylisted(OsStr::new("lib.rs"))); + assert!(!is_warned(OsStr::new("Cargo.toml"))); + assert!(!is_warned(OsStr::new("lib.rs"))); } // Initialize a git repo at `root` with one commit of everything present. @@ -353,7 +366,13 @@ mod tests { let a = build_source_archive(root, &print, true).unwrap(); let b = build_source_archive(root, &print, true).unwrap(); assert!(!a.is_empty()); - assert_eq!(a, b, "same commit should produce identical bytes"); + assert_eq!(a, b, "same tree should produce identical bytes"); + + // The `.git` dir git_init_commit created is never archived. + assert!(entry_names(&a) + .unwrap() + .iter() + .all(|n| !n.starts_with("source/.git/"))); let sha = hex::encode(Sha256::digest(&a)); assert_eq!(sha.len(), 64); @@ -385,18 +404,20 @@ mod tests { } #[test] - fn build_source_archive_non_git_excludes_denylist() { + fn build_source_archive_skips_git_dir_and_is_reproducible() { let print = Print::new(true); let temp = tempfile::TempDir::new().unwrap(); let root = temp.path(); std::fs::write(root.join("Cargo.toml"), b"# crate").unwrap(); std::fs::create_dir_all(root.join("src")).unwrap(); std::fs::write(root.join("src/lib.rs"), b"// code").unwrap(); - // Planted dirs that must be excluded. - std::fs::create_dir_all(root.join("target/debug")).unwrap(); - std::fs::write(root.join("target/debug/x"), b"junk").unwrap(); + // A `.git` dir is always skipped, even without a real repo. std::fs::create_dir_all(root.join(".git")).unwrap(); std::fs::write(root.join(".git/config"), b"junk").unwrap(); + // No `.gitignore`, so `target/` is NOT excluded — selection is driven by + // ignore files only. + std::fs::create_dir_all(root.join("target/debug")).unwrap(); + std::fs::write(root.join("target/debug/x"), b"junk").unwrap(); let bytes = build_source_archive(root, &print, true).unwrap(); let dest = tempfile::TempDir::new().unwrap(); @@ -404,8 +425,9 @@ mod tests { assert!(dest.path().join("source/Cargo.toml").exists()); assert!(dest.path().join("source/src/lib.rs").exists()); - assert!(!dest.path().join("source/target").exists()); assert!(!dest.path().join("source/.git").exists()); + // Un-ignored `target/` is included (and would have triggered a warning). + assert!(dest.path().join("source/target/debug/x").exists()); assert_eq!(hex::encode(Sha256::digest(&bytes)).len(), 64); // Reproducible: a second run over the same tree yields identical bytes @@ -415,36 +437,37 @@ mod tests { } #[test] - fn resolve_source_root_finds_git_root_from_subdir() { + fn build_source_archive_respects_gitignore_and_dot_ignore() { + let print = Print::new(true); let temp = tempfile::TempDir::new().unwrap(); let root = temp.path(); - std::fs::create_dir_all(root.join(".git")).unwrap(); - let nested = root.join("contracts").join("foo"); - std::fs::create_dir_all(&nested).unwrap(); - std::fs::write(nested.join("Cargo.toml"), b"# placeholder").unwrap(); - - let manifest = nested.join("Cargo.toml"); - // Use canonicalize on both sides — `tempfile` returns symlinked /var - // paths on macOS while resolve_source_root walks the same prefix. - let got = std::fs::canonicalize(resolve_source_root(Some(&manifest))).unwrap(); - let want = std::fs::canonicalize(root).unwrap(); - assert_eq!(got, want); + std::fs::write(root.join("Cargo.toml"), b"# crate").unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("src/lib.rs"), b"// code").unwrap(); + // `.gitignore` and `.ignore` are honored even without a git repo. + std::fs::write(root.join(".gitignore"), b"target/\n").unwrap(); + std::fs::write(root.join(".ignore"), b"secret.txt\n").unwrap(); + std::fs::create_dir_all(root.join("target/debug")).unwrap(); + std::fs::write(root.join("target/debug/x"), b"junk").unwrap(); + std::fs::write(root.join("secret.txt"), b"shh").unwrap(); + + let bytes = build_source_archive(root, &print, true).unwrap(); + let dest = tempfile::TempDir::new().unwrap(); + unpack_targz(&bytes, dest.path()).unwrap(); + + assert!(dest.path().join("source/Cargo.toml").exists()); + assert!(dest.path().join("source/src/lib.rs").exists()); + // Excluded by the in-tree ignore files. + assert!(!dest.path().join("source/target").exists()); + assert!(!dest.path().join("source/secret.txt").exists()); + // The ignore files themselves are archived like any other tracked file. + assert!(dest.path().join("source/.gitignore").exists()); } #[test] - fn resolve_source_root_falls_back_to_cwd_without_git() { - let temp = tempfile::TempDir::new().unwrap(); - let root = temp.path(); - let nested = root.join("noisy"); - std::fs::create_dir_all(&nested).unwrap(); - std::fs::write(nested.join("Cargo.toml"), b"# placeholder").unwrap(); - - let manifest = nested.join("Cargo.toml"); - // No `.git` anywhere up the tree, so we fall back to cwd. We can't - // assert what cwd is in a test runner (it varies), but we can assert - // that the returned path doesn't have `.git`. That's enough to confirm - // fallback kicked in. - let got = resolve_source_root(Some(&manifest)); - assert!(!got.join(".git").exists()); + fn resolve_source_root_is_cwd() { + // The root is always the current working directory — no upward search, + // no manifest anchoring. + assert_eq!(resolve_source_root(), std::env::current_dir().unwrap()); } } diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 1c6c15ba0a..ecad61fc38 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -69,11 +69,6 @@ pub enum Error { #[error(transparent)] SourceArchive(#[from] source_archive::Error), - #[error( - "git working tree at {path} is dirty. --verifiable requires a clean tree so the recorded source_sha256 matches the WASM bytes. Commit or stash your changes and try again." - )] - GitDirty { path: PathBuf }, - #[error( "the cli sets bldimg, source_uri, source_sha256, and bldopt automatically when --verifiable is used; remove them from --meta. Got reserved key: {key}" )] @@ -116,17 +111,18 @@ pub async fn run( let workspace_root = resolve_workspace_root(cmd)?; validate_source_formats(cmd)?; - // Pick the anchor for the local source: the `--manifest-path` bldopt is - // relativized against it, and (when `--archive` is not used) it's also what - // gets bind-mounted into the container. We do NOT validate that it matches - // source_uri — a wrong source produces different bytes, and verify catches - // that at byte-comparison time. - let source_root = source_archive::resolve_source_root(cmd.manifest_path.as_deref()); + // The source root is the current working directory: it's bind-mounted into + // the container and the `--manifest-path` bldopt is relativized against it. + // Run from the project/workspace root you want built. We do NOT validate that + // it matches source_uri — a wrong source produces different bytes, and verify + // catches that at byte-comparison time. + let source_root = source_archive::resolve_source_root(); - // A dirty working tree would make the recorded source_sha256 fail to - // describe the bytes actually built, so refuse to proceed. Skipped when - // the source root isn't a git repo (we can't check, e.g. archive sources). - enforce_clean_tree(&source_root)?; + // The archive is the working tree, so refuse a dirty repo: a verifiable build + // should be deliberate, off a committed state, not whatever happens to be on + // disk. Skipped when the source root isn't a git repo (we can't check, e.g. + // archive sources). + source_archive::ensure_clean_tree(&source_root, print).map_err(Error::from)?; // Always build the source archive, record its hash, and build from the // *extracted* archive (in a hardened tempdir) so the WASM is produced from @@ -344,20 +340,6 @@ fn resolve_archive(cmd: &Cmd, source_root: &Path, print: &Print) -> Result Result<(), Error> { - if source_archive::tree_is_dirty(source_root)? { - return Err(Error::GitDirty { - path: source_root.to_path_buf(), - }); - } - Ok(()) -} - fn bldimg_regex() -> Regex { Regex::new(r"^(?:localhost(?::\d+)?|[^\s@/]*[.:][^\s@/]*)/[^\s@]+@sha256:[0-9a-f]{64}$") .unwrap() From 183360666226329601824b66d2d7647745ebc9ac Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 8 Jul 2026 11:27:06 -0300 Subject: [PATCH 22/58] Skip --locked on build images that lack it. --- .../src/commands/contract/build/verifiable.rs | 191 ++++++++++++------ 1 file changed, 125 insertions(+), 66 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index ecad61fc38..ebda60f7d1 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -147,12 +147,6 @@ pub async fn run( source_sha256: Some(resolved.source_sha256.clone()), }; - // Defer the info banner until every validation has passed, so it doesn't - // appear right before an error. - if !cmd.locked { - print.infoln("Implying --locked because --verifiable was passed"); - } - // Stage 3: docker. let docker_args = crate::commands::container::shared::Args { docker_host: cmd.docker_host.clone(), @@ -172,6 +166,23 @@ pub async fn run( probe_supports_optimize_false_syntax(&image_ref, &docker, print).await }; + // `--locked` is implied by `--verifiable`, but it was only added to + // `contract build` in cli 25.2.0. Probe the image before adding it so a + // build against an older, still-valid bldimg doesn't fail on an unknown + // flag. When the flag is unavailable we drop it and warn that the rebuild + // can't be pinned against dependency drift. + let supports_locked = probe_supports_locked(&image_ref, &docker, print).await; + if supports_locked { + if !cmd.locked { + print.infoln("Implying --locked because --verifiable was passed"); + } + } else { + print.warnln( + "The build image's `contract build` does not support --locked; \ + building without it. Dependency drift may affect reproducibility.", + ); + } + // Build once per package, each with its own `--package` forwarded and // recorded as a `bldopt`, so every WASM is independently reproducible. With // no explicit `--package` the targets are inferred like a regular build. @@ -187,8 +198,13 @@ pub async fn run( let container_cmds: Vec> = targets .iter() .map(|target| { - let (forwarded_args, bldopts) = - build_forwarded_args(cmd, &source_root, *target, supports_explicit_optimize_false); + let (forwarded_args, bldopts) = build_forwarded_args( + cmd, + &source_root, + *target, + supports_explicit_optimize_false, + supports_locked, + ); let metadata_args = build_metadata_args(&image_ref, &source_ids, &bldopts); compose_container_args(&forwarded_args, &metadata_args) }) @@ -389,8 +405,13 @@ fn resolve_build_packages(cmd: &Cmd) -> Result, Error> { /// The flags forwarded to the container's `stellar contract build`, plus the /// bldopt strings recorded into SEP-58 metadata. Every build-affecting flag /// becomes one bldopt entry so a verifier can replay the same invocation. -/// `--locked` is always present. `manifest_path` (when set) is recorded -/// relative to the workspace root so it's valid inside `/source`. +/// `manifest_path` (when set) is recorded relative to the workspace root so it's +/// valid inside `/source`. +/// +/// `supports_locked`: whether the container's `contract build` accepts +/// `--locked` (added in cli 25.2.0). When false the flag is neither forwarded +/// nor recorded, so a build against an older image doesn't fail on an unknown +/// argument. /// /// `supports_explicit_optimize_false`: whether the container's cli accepts /// `--optimize=false`. When false, the optimize=false case records the flag @@ -401,6 +422,7 @@ fn build_forwarded_args( workspace_root: &Path, package: Option<&str>, supports_explicit_optimize_false: bool, + supports_locked: bool, ) -> (Vec, Vec) { let mut forwarded: Vec = Vec::new(); let mut bldopts: Vec = Vec::new(); @@ -424,7 +446,9 @@ fn build_forwarded_args( } }; - record("--locked", None); + if supports_locked { + record("--locked", None); + } if let Some(path) = &cmd.manifest_path { let abs = std::path::absolute(path).unwrap_or_else(|_| path.clone()); @@ -714,10 +738,20 @@ async fn probe_supports_optimize_false_syntax( } } -async fn probe_cli_version(image_ref: &str, docker: &Docker) -> Result { +/// Run `cmd` in a throwaway container (optionally overriding the entrypoint) and +/// return its captured stdout. The container auto-removes; stderr is attached so +/// the daemon streams it, but only stdout is collected. Shared by every image +/// probe (cli version, active toolchain, flag support). +async fn run_probe( + image_ref: &str, + docker: &Docker, + entrypoint: Option>, + cmd: Vec, +) -> Result { let config = ContainerCreateBody { image: Some(image_ref.to_string()), - cmd: Some(vec!["version".to_string(), "--only-version".to_string()]), + entrypoint, + cmd: Some(cmd), attach_stdout: Some(true), attach_stderr: Some(true), host_config: Some(HostConfig { @@ -755,10 +789,50 @@ async fn probe_cli_version(image_ref: &str, docker: &Docker) -> Result); while wait.next().await.is_some() {} + Ok(stdout) +} + +async fn probe_cli_version(image_ref: &str, docker: &Docker) -> Result { + let stdout = run_probe( + image_ref, + docker, + None, + vec!["version".to_string(), "--only-version".to_string()], + ) + .await?; Version::parse(stdout.trim()) .map_err(|e| Error::TagListUnavailable(format!("unparseable version {stdout:?}: {e}"))) } +/// Probe whether the container's `stellar contract build` accepts `--locked`. +/// The flag was added in cli 25.2.0 (commit `6115b818`); older images reject it +/// outright, which would fail the build. Rather than map versions, ask the +/// container's own `contract build --help` whether the flag exists. On any probe +/// failure returns false — the conservative assumption that the flag is absent, +/// so the build proceeds without it rather than erroring. +pub(crate) async fn probe_supports_locked(image_ref: &str, docker: &Docker, print: &Print) -> bool { + match run_probe( + image_ref, + docker, + None, + vec![ + "contract".to_string(), + "build".to_string(), + "--help".to_string(), + ], + ) + .await + { + Ok(help) => help.contains("--locked"), + Err(e) => { + print.warnln(format!( + "Could not probe whether the container's `contract build` supports --locked ({e}); building without it" + )); + false + } + } +} + /// Probe the image for the toolchain rustup uses by default, so it can be /// pinned via `RUSTUP_TOOLCHAIN` (see `run_in_container`). Overrides the /// entrypoint to run `rustup show active-toolchain` and returns the toolchain @@ -767,50 +841,14 @@ async fn probe_cli_version(image_ref: &str, docker: &Docker) -> Result Option { - let config = ContainerCreateBody { - image: Some(image_ref.to_string()), - entrypoint: Some(vec!["rustup".to_string()]), - cmd: Some(vec!["show".to_string(), "active-toolchain".to_string()]), - attach_stdout: Some(true), - attach_stderr: Some(true), - host_config: Some(HostConfig { - auto_remove: Some(true), - ..Default::default() - }), - ..Default::default() - }; - let created = docker - .create_container(None::, config) - .await - .ok()?; - let attached = docker - .attach_container( - &created.id, - Some(AttachContainerOptions { - stdout: true, - stderr: true, - stream: true, - ..Default::default() - }), - ) - .await - .ok()?; - docker - .start_container(&created.id, None::) - .await - .ok()?; - - let mut stdout = String::new(); - let mut output = attached.output; - while let Some(chunk) = output.next().await { - if let Ok(bollard::container::LogOutput::StdOut { message }) = chunk { - stdout.push_str(&String::from_utf8_lossy(&message)); - } - } - - let mut wait = docker.wait_container(&created.id, None::); - while wait.next().await.is_some() {} - + let stdout = run_probe( + image_ref, + docker, + Some(vec!["rustup".to_string()]), + vec!["show".to_string(), "active-toolchain".to_string()], + ) + .await + .ok()?; stdout.split_whitespace().next().map(str::to_string) } @@ -1076,7 +1114,8 @@ mod tests { #[test] fn build_forwarded_args_defaults() { let cmd = Cmd::default(); - let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), true); + let (forwarded, bldopts) = + build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), true, true); // Default optimize=true → bare `--optimize` recorded + forwarded. assert_eq!( forwarded, @@ -1088,6 +1127,19 @@ mod tests { ); } + #[test] + fn build_forwarded_args_omits_locked_when_unsupported() { + // Older images (< cli 25.2.0) reject `--locked`; when the probe reports + // it's unsupported, the flag is neither forwarded nor recorded. + let cmd = Cmd::default(); + let (forwarded, bldopts) = + build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), true, false); + assert!(!forwarded.iter().any(|a| a == "--locked")); + assert!(!bldopts.iter().any(|a| a == "--locked")); + // Everything else is still recorded (default optimize=true here). + assert!(forwarded.contains(&"--optimize".to_string())); + } + #[test] fn build_forwarded_args_features_and_package() { let cmd = Cmd { @@ -1095,7 +1147,8 @@ mod tests { package: Some("contract-a".to_string()), ..Cmd::default() }; - let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), true); + let (forwarded, bldopts) = + build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), true, true); assert!(forwarded.contains(&"--features=a,b".to_string())); assert!(forwarded.contains(&"--package=contract-a".to_string())); assert!(bldopts.contains(&"--features=a,b".to_string())); @@ -1109,7 +1162,8 @@ mod tests { // default cdylib); it must still be forwarded and recorded. let cmd = Cmd::default(); assert!(cmd.package.is_none()); - let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), Some("hello-world"), true); + let (forwarded, bldopts) = + build_forwarded_args(&cmd, ws(), Some("hello-world"), true, true); assert!(forwarded.contains(&"--package=hello-world".to_string())); assert!(bldopts.contains(&"--package=hello-world".to_string())); } @@ -1117,7 +1171,7 @@ mod tests { #[test] fn build_forwarded_args_omits_package_when_unresolved() { let cmd = Cmd::default(); - let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), None, true); + let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), None, true, true); assert!(!forwarded.iter().any(|a| a.starts_with("--package"))); assert!(!bldopts.iter().any(|a| a.starts_with("--package"))); } @@ -1136,7 +1190,8 @@ mod tests { }, ..Cmd::default() }; - let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), true); + let (forwarded, bldopts) = + build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), true, true); assert!(forwarded.contains(&"--meta=home_domain=fnando.com".to_string())); assert!(forwarded.contains(&"--meta=author=alice".to_string())); assert!(forwarded.contains(&"--manifest-path=contracts/add/Cargo.toml".to_string())); @@ -1157,7 +1212,8 @@ mod tests { }, ..Cmd::default() }; - let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), true); + let (forwarded, bldopts) = + build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), true, true); // Env vars are applied via docker `-e`, so they're recorded as bldopts // for the verifier but never forwarded as build arguments. assert!(bldopts.contains(&"--env=FOO=bar".to_string())); @@ -1175,7 +1231,8 @@ mod tests { }, ..Cmd::default() }; - let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), true); + let (forwarded, bldopts) = + build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), true, true); assert!(forwarded.contains(&"--optimize=false".to_string())); assert!(bldopts.contains(&"--optimize=false".to_string())); } @@ -1190,7 +1247,8 @@ mod tests { }, ..Cmd::default() }; - let (forwarded, bldopts) = build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), false); + let (forwarded, bldopts) = + build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), false, true); // Old container's default is already false; record nothing. // Passing `--optimize=false` to a pre-26.1.0 cli would fail. assert!(!forwarded.iter().any(|a| a.starts_with("--optimize"))); @@ -1248,7 +1306,8 @@ mod tests { }, ..Cmd::default() }; - let (_forwarded, bldopts) = build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), true); + let (_forwarded, bldopts) = + build_forwarded_args(&cmd, ws(), cmd.package.as_deref(), true, true); // The flag and key stay outside the quotes; only the value is escaped. assert!(bldopts.contains(&"--env=B='this is very nice'".to_string())); From ef4993c2162b0efee32c7c1a92f0dc02a6b9bdf8 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 16 Jul 2026 11:13:49 -0300 Subject: [PATCH 23/58] Run verifiable builds through the docker CLI. --- Cargo.lock | 10 +- .../src/commands/container/shared.rs | 62 +++- .../src/commands/contract/build/verifiable.rs | 310 ++++++------------ 3 files changed, 171 insertions(+), 211 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 260212a41a..aba5091f09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2382,9 +2382,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "globset" -version = "0.4.16" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" dependencies = [ "aho-corasick", "bstr", @@ -2959,9 +2959,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.23" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" +checksum = "d4ffa3a0547a138e59ddd6fa3b7c672ed47e6ad6a3cd177984ff1116aa5ba742" dependencies = [ "crossbeam-deque", "globset", @@ -5410,6 +5410,7 @@ dependencies = [ "hex", "home", "humantime", + "ignore", "indexmap 2.11.0", "itertools 0.10.5", "jsonrpsee-types", @@ -5464,7 +5465,6 @@ dependencies = [ "tracing-subscriber", "ulid", "url", - "walkdir", "wasm-gen", "wasm-opt", "wasmparser 0.116.1", diff --git a/cmd/soroban-cli/src/commands/container/shared.rs b/cmd/soroban-cli/src/commands/container/shared.rs index b621aea9ca..d4435ab502 100644 --- a/cmd/soroban-cli/src/commands/container/shared.rs +++ b/cmd/soroban-cli/src/commands/container/shared.rs @@ -1,6 +1,8 @@ use core::fmt; +use std::process::Stdio; use clap::ValueEnum; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; use tokio::process::Command; use crate::print::Print; @@ -20,6 +22,9 @@ pub enum Error { program: String, source: std::io::Error, }, + + #[error("could not pull image {image}: {stderr}")] + PullImageFailed { image: String, stderr: String }, } /// Container runtime to shell out to. @@ -191,7 +196,7 @@ impl Args { /// `--docker-host` (or `DOCKER_HOST` env) value is passed as `-H `; the /// `-H` flag outranks `DOCKER_CONTEXT`, so the override is honored even when a /// docker context is active. Host resolution is otherwise left to the CLI. - fn base_command(&self) -> Command { + pub(crate) fn base_command(&self) -> Command { let engine = self.engine(); let mut cmd = Command::new(engine.program()); if engine.supports_docker_host() { @@ -236,6 +241,59 @@ impl Args { }; cmd } + + /// Pull `image`, streaming the engine's high-level status lines ("Pulling + /// from", "Digest", "Status") through `print`. Per-layer progress written to + /// stderr is captured rather than shown and surfaced only when the pull + /// fails, as `PullImageFailed` — callers that need to explain a failed pull + /// (e.g. the verifiable build's tag-listing hint) rely on that captured text. + /// A missing engine binary surfaces via `io_error` as `NotFound`. + pub(crate) async fn pull_image(&self, image: &str, print: &Print) -> Result<(), Error> { + let mut child = self + .pull_command(image) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| self.io_error(e))?; + + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + + let stream_stdout = async { + if let Some(stdout) = stdout { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.contains("Pulling from") + || line.contains("Digest") + || line.contains("Status") + { + print.infoln(line); + } + } + } + }; + + let capture_stderr = async { + let mut buf = String::new(); + if let Some(mut stderr) = stderr { + let _ = stderr.read_to_string(&mut buf).await; + } + buf + }; + + // Drain both pipes concurrently so a full stderr buffer can't deadlock + // the child while we're reading stdout. + let ((), stderr) = tokio::join!(stream_stdout, capture_stderr); + + if child.wait().await.map_err(|e| self.io_error(e))?.success() { + Ok(()) + } else { + Err(Error::PullImageFailed { + image: image.to_string(), + stderr: stderr.trim().to_string(), + }) + } + } } /// Resource limits for commands that *run* a container (e.g. `container start`). @@ -458,7 +516,7 @@ mod test { let not_found = std::io::Error::from(std::io::ErrorKind::NotFound); match args(None, Some(Engine::AppleContainer)).io_error(not_found) { Error::NotFound { program, .. } => assert_eq!(program, "container"), - Error::Command { .. } => panic!("expected NotFound, got Command"), + other => panic!("expected NotFound, got {other:?}"), } } diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index ebda60f7d1..7ca08ec649 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -1,23 +1,17 @@ use std::path::{Path, PathBuf}; +use std::process::Stdio; -use bollard::{ - models::ContainerCreateBody, - query_parameters::{ - AttachContainerOptions, CreateContainerOptions, CreateImageOptions, StartContainerOptions, - WaitContainerOptions, - }, - service::HostConfig, - Docker, -}; use cargo_metadata::MetadataCommand; -use futures_util::{StreamExt, TryStreamExt}; use regex::Regex; use semver::Version; use serde::Deserialize; use sha2::{Digest, Sha256}; use crate::{ - commands::{container::shared::Error as ConnectionError, global}, + commands::{ + container::shared::{self, Error as ConnectionError}, + global, + }, config::{data, locator::enforce_hardened_tree}, print::Print, }; @@ -37,11 +31,8 @@ const OPTIMIZE_NEW_SYNTAX_MIN: &str = "26.1.0"; #[derive(thiserror::Error, Debug)] pub enum Error { - #[error("⛔ failed to connect to docker: {0}")] - DockerConnection(#[from] ConnectionError), - #[error(transparent)] - Bollard(#[from] bollard::errors::Error), + DockerConnection(#[from] ConnectionError), #[error("--image value {value:?} does not match the SEP-58 bldimg format `/@sha256:<64-hex>`. Examples: docker.io/stellar/stellar-cli@sha256:<64-hex>, localhost:5000/foo@sha256:<64-hex>. Tag-only refs and implicit Docker-Hub short refs are not accepted.")] BldimgFormat { value: String }, @@ -49,12 +40,12 @@ pub enum Error { #[error("could not determine the running rustc version: {0}")] RustcVersion(String), - #[error("could not pull image {tag}: {source}\n\nAvailable tags for this CLI version: {available_for_cli}\nAll published cli/rust pairs: {all_grouped}\n\nFix: install a matching rustc, or pass --image docker.io/stellar/stellar-cli@sha256: with one of the listed tags resolved to a digest.")] + #[error("could not pull image {tag}: {detail}\n\nAvailable tags for this CLI version: {available_for_cli}\nAll published cli/rust pairs: {all_grouped}\n\nFix: install a matching rustc, or pass --image docker.io/stellar/stellar-cli@sha256: with one of the listed tags resolved to a digest.")] ImageNotFound { tag: String, available_for_cli: String, all_grouped: String, - source: bollard::errors::Error, + detail: String, }, #[error("could not list published images on docker hub: {0}")] @@ -147,14 +138,15 @@ pub async fn run( source_sha256: Some(resolved.source_sha256.clone()), }; - // Stage 3: docker. - let docker_args = crate::commands::container::shared::Args { + // Stage 3: docker. Every docker interaction shells out to the container + // engine through this `Args` (honoring `--docker-host`). Verifiable builds + // pin the engine to docker (`engine: None` → the default): the probes, + // `inspect`, and reproduce lines below are docker-specific, and SEP-58 + // reproducibility depends on that exact toolchain. + let docker = shared::Args { docker_host: cmd.docker_host.clone(), + engine: None, }; - let docker = docker_args - .connect_to_docker(print) - .await - .map_err(Error::DockerConnection)?; let image_ref = resolve_image(cmd, &docker, print).await?; // Only probe the container's cli version when we need to pick between @@ -540,16 +532,20 @@ fn compose_container_args(forwarded: &[String], metadata: &[String]) -> Vec Result { +pub async fn resolve_image( + cmd: &Cmd, + docker: &shared::Args, + print: &Print, +) -> Result { if let Some(s) = &cmd.image { if !bldimg_regex().is_match(s) { return Err(Error::BldimgFormat { value: s.clone() }); } // Always pull, even when the digest is user-supplied. Docker requires - // the image to be locally present before `create_container` will - // accept it, and the user typically expects the cli to fetch - // whatever they asked for. - pull_image(docker, s, print).await?; + // the image to be locally present before `docker run` will accept it, + // and the user typically expects the cli to fetch whatever they asked + // for. + docker.pull_image(s, print).await?; return Ok(s.clone()); } @@ -560,11 +556,13 @@ pub async fn resolve_image(cmd: &Cmd, docker: &Docker, print: &Print) -> Result< let tag = format!("{REGISTRY}:{cli_v}-rust{rust_v}"); print.infoln(format!("Pulling verifiable build image {tag}")); - let pull = pull_image(docker, &tag, print).await; - match pull { + match docker.pull_image(&tag, print).await { Ok(()) => {} - Err(e) => { + // A failed pull of the derived cli/rust tag usually means no image was + // published for this pair; turn it into the tag-listing hint. A missing + // `docker` binary (or other connection failure) propagates as-is. + Err(ConnectionError::PullImageFailed { stderr, .. }) => { let (available_for_cli, all_grouped) = match list_published_tags().await { Ok(tags) => format_available(&tags, cli_v), Err(list_err) => ( @@ -576,53 +574,33 @@ pub async fn resolve_image(cmd: &Cmd, docker: &Docker, print: &Print) -> Result< tag, available_for_cli, all_grouped, - source: e, + detail: stderr, }); } + Err(e) => return Err(Error::DockerConnection(e)), } - let inspect = docker.inspect_image(&tag).await?; - let digest = inspect - .repo_digests - .and_then(|v| v.into_iter().next()) - .ok_or_else(|| Error::NoRepoDigest { tag: tag.clone() })?; - Ok(digest) + image_repo_digest(docker, &tag).await } -async fn pull_image( - docker: &Docker, - tag: &str, - print: &Print, -) -> Result<(), bollard::errors::Error> { - let mut stream = docker.create_image( - Some(CreateImageOptions { - from_image: Some(tag.to_string()), - ..Default::default() - }), - None, - None, - ); - while let Some(item) = stream.try_next().await? { - if let Some(status) = item.status { - // The docker daemon emits short status lines like: - // "Pulling from " - // "Digest: sha256:" - // "Status: Image is up to date for " - // Stand-alone "Digest" reads as an orphan. Rewrite each line so - // it makes sense outside the docker-pull context. - if let Some(repo) = status.strip_prefix("Pulling from ") { - print.infoln(format!("Pulling image {repo}")); - } else if let Some(digest) = status.strip_prefix("Digest: ") { - print.infoln(format!("Image digest: {digest}")); - } else if let Some(rest) = status.strip_prefix("Status: ") { - // Docker's status text already starts with "Image …" or - // "Downloaded …", so we forward it verbatim instead of - // prepending another "Image:". - print.infoln(rest); - } - } +/// Resolve a locally-present image to its content-addressed repo digest +/// (`@sha256:`) via `docker inspect`, so the recorded `bldimg` pins +/// the exact bytes that were pulled rather than a mutable tag. +async fn image_repo_digest(docker: &shared::Args, tag: &str) -> Result { + let output = docker + .base_command() + .args(["inspect", "--format", "{{index .RepoDigests 0}}", tag]) + .output() + .await + .map_err(|e| docker.io_error(e))?; + + let digest = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !output.status.success() || digest.is_empty() || digest == "" { + return Err(Error::NoRepoDigest { + tag: tag.to_string(), + }); } - Ok(()) + Ok(digest) } #[derive(Debug, Clone)] @@ -721,7 +699,7 @@ fn format_available(tags: &[PublishedTag], current_cli: &str) -> (String, String /// false — the conservative assumption that the container is old. async fn probe_supports_optimize_false_syntax( image_ref: &str, - docker: &Docker, + docker: &shared::Args, print: &Print, ) -> bool { match probe_cli_version(image_ref, docker).await { @@ -738,61 +716,30 @@ async fn probe_supports_optimize_false_syntax( } } -/// Run `cmd` in a throwaway container (optionally overriding the entrypoint) and -/// return its captured stdout. The container auto-removes; stderr is attached so -/// the daemon streams it, but only stdout is collected. Shared by every image -/// probe (cli version, active toolchain, flag support). +/// Run `cmd` in a throwaway `docker run --rm` container (optionally overriding +/// the entrypoint) and return its captured stdout. Only stdout is collected; +/// stderr and the exit status are ignored, matching how every probe treats a +/// missing subcommand or unexpected output as "unsupported". Shared by every +/// image probe (cli version, active toolchain, flag support). async fn run_probe( image_ref: &str, - docker: &Docker, - entrypoint: Option>, + docker: &shared::Args, + entrypoint: Option<&str>, cmd: Vec, ) -> Result { - let config = ContainerCreateBody { - image: Some(image_ref.to_string()), - entrypoint, - cmd: Some(cmd), - attach_stdout: Some(true), - attach_stderr: Some(true), - host_config: Some(HostConfig { - auto_remove: Some(true), - ..Default::default() - }), - ..Default::default() - }; - let created = docker - .create_container(None::, config) - .await?; - let attached = docker - .attach_container( - &created.id, - Some(AttachContainerOptions { - stdout: true, - stderr: true, - stream: true, - ..Default::default() - }), - ) - .await?; - docker - .start_container(&created.id, None::) - .await?; - - let mut stdout = String::new(); - let mut output = attached.output; - while let Some(chunk) = output.next().await { - if let Ok(bollard::container::LogOutput::StdOut { message }) = chunk { - stdout.push_str(&String::from_utf8_lossy(&message)); - } + let mut command = docker.base_command(); + command.args(["run", "--rm"]); + if let Some(entrypoint) = entrypoint { + command.args(["--entrypoint", entrypoint]); } + command.arg(image_ref); + command.args(&cmd); - let mut wait = docker.wait_container(&created.id, None::); - while wait.next().await.is_some() {} - - Ok(stdout) + let output = command.output().await.map_err(|e| docker.io_error(e))?; + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } -async fn probe_cli_version(image_ref: &str, docker: &Docker) -> Result { +async fn probe_cli_version(image_ref: &str, docker: &shared::Args) -> Result { let stdout = run_probe( image_ref, docker, @@ -810,7 +757,11 @@ async fn probe_cli_version(image_ref: &str, docker: &Docker) -> Result bool { +pub(crate) async fn probe_supports_locked( + image_ref: &str, + docker: &shared::Args, + print: &Print, +) -> bool { match run_probe( image_ref, docker, @@ -840,11 +791,11 @@ pub(crate) async fn probe_supports_locked(image_ref: &str, docker: &Docker, prin /// `(default)` marker (e.g. `1.93.0-x86_64-unknown-linux-gnu`). Returns `None` /// on any failure (e.g. an image without rustup), so the build proceeds without /// the pin rather than failing. -async fn probe_active_toolchain(image_ref: &str, docker: &Docker) -> Option { +async fn probe_active_toolchain(image_ref: &str, docker: &shared::Args) -> Option { let stdout = run_probe( image_ref, docker, - Some(vec!["rustup".to_string()]), + Some("rustup"), vec!["show".to_string(), "active-toolchain".to_string()], ) .await @@ -886,7 +837,7 @@ async fn run_in_container( workspace_root: &Path, container_cmds: &[Vec], env: &[String], - docker: &Docker, + docker: &shared::Args, print: &Print, verbose: bool, ) -> Result<(), Error> { @@ -912,20 +863,18 @@ async fn run_in_container( env_flags.push_str(&shell_escape::escape(e.as_str().into())); } - // One package → run the image's default `stellar` entrypoint directly. - // Several → override the entrypoint to a shell and chain the builds so they - // all run in this one container. - let (entrypoint, cmd, reproduce) = if container_cmds.len() > 1 { + // One package → run the image's default `stellar` entrypoint directly, so + // `post_image` is just the `contract build …` argv. Several → override the + // entrypoint to a shell and chain the builds so they all run in this one + // container; `--entrypoint` takes only the executable, so the `-c ` + // arguments follow the image name in `post_image`. + let (entrypoint, post_image, reproduce) = if container_cmds.len() > 1 { let chain = compose_shell_command(container_cmds); let reproduce = format!( "docker run --rm -v {bind}{env_flags} --entrypoint /bin/sh {image_ref} -c {}", shell_escape::escape(chain.clone().into()) ); - ( - Some(vec!["/bin/sh".to_string(), "-c".to_string()]), - vec![chain], - reproduce, - ) + (Some("/bin/sh"), vec!["-c".to_string(), chain], reproduce) } else { let cmd = container_cmds.first().cloned().unwrap_or_default(); let reproduce = format!( @@ -935,22 +884,6 @@ async fn run_in_container( (None, cmd, reproduce) }; - let config = ContainerCreateBody { - image: Some(image_ref.to_string()), - entrypoint, - cmd: Some(cmd), - env: (!env.is_empty()).then(|| env.clone()), - working_dir: Some("/source".to_string()), - attach_stdout: Some(true), - attach_stderr: Some(true), - host_config: Some(HostConfig { - auto_remove: Some(true), - binds: Some(vec![bind.clone()]), - ..Default::default() - }), - ..Default::default() - }; - print.infoln(format!( "Running verifiable build in {image_ref} (mount {bind})" )); @@ -958,65 +891,34 @@ async fn run_in_container( print.infoln(format!("Running: {reproduce}")); } - let created = docker - .create_container(None::, config) - .await?; - - let attached = docker - .attach_container( - &created.id, - Some(AttachContainerOptions { - stdout: true, - stderr: true, - stream: true, - ..Default::default() - }), - ) - .await?; - - docker - .start_container(&created.id, None::) - .await?; - - let mut output = attached.output; - while let Some(chunk) = output.next().await { - match chunk { - Ok( - bollard::container::LogOutput::StdOut { message } - | bollard::container::LogOutput::StdErr { message }, - ) => { - if verbose { - let s = String::from_utf8_lossy(&message); - print.blankln(s.trim_end()); - } - } - Ok(_) => {} - Err(e) => return Err(e.into()), - } + let mut command = docker.base_command(); + command.args(["run", "--rm", "-v", &bind, "-w", "/source"]); + for e in &env { + command.args(["-e", e]); + } + if let Some(entrypoint) = entrypoint { + command.args(["--entrypoint", entrypoint]); } + command.arg(image_ref); + command.args(&post_image); - wait_for_container_exit(docker, &created.id, &reproduce).await -} + // Stream the build's cargo output straight to the terminal when verbose + // (matching a non-verifiable `contract build`); otherwise discard it (the + // verify pipeline suppresses per-build noise). `quiet` overrides verbose. + let show_output = verbose && !print.quiet; + let (stdout, stderr) = if show_output { + (Stdio::inherit(), Stdio::inherit()) + } else { + (Stdio::null(), Stdio::null()) + }; + command.stdout(stdout).stderr(stderr); -/// Block until the container exits, mapping a non-zero exit code (whether -/// reported as a successful wait or as a `DockerContainerWaitError`) to -/// `ContainerExit` carrying the reproduce command. -async fn wait_for_container_exit(docker: &Docker, id: &str, reproduce: &str) -> Result<(), Error> { - let mut wait = docker.wait_container(id, None::); - while let Some(item) = wait.next().await { - // Both a successful wait and a `DockerContainerWaitError` carry an exit - // code; normalize to it (other errors are genuine failures). - let status = match item { - Ok(r) => r.status_code, - Err(bollard::errors::Error::DockerContainerWaitError { code, .. }) => code, - Err(e) => return Err(e.into()), - }; - if status != 0 { - return Err(Error::ContainerExit { - status, - command: reproduce.to_string(), - }); - } + let status = command.status().await.map_err(|e| docker.io_error(e))?; + if !status.success() { + return Err(Error::ContainerExit { + status: status.code().unwrap_or(-1).into(), + command: reproduce, + }); } Ok(()) From b55638291f20fe2d877e1a20e1feb27ec7050c6c Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 16 Jul 2026 21:27:47 -0300 Subject: [PATCH 24/58] Support other container engines in verifiable builds. --- FULL_HELP_DOCS.md | 15 +- .../src/commands/container/shared.rs | 149 +++++++++++++++++- .../src/commands/contract/build.rs | 32 ++-- .../src/commands/contract/build/verifiable.rs | 66 ++++---- cmd/soroban-cli/src/commands/mod.rs | 2 + 5 files changed, 218 insertions(+), 46 deletions(-) diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index 4d10b321b9..f5b1b7dab5 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -368,6 +368,18 @@ To view the commands that will be executed, without executing them, use the --pr **Usage:** `stellar contract build [OPTIONS]` +###### **Container Options:** + +- `-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 +- `--engine ` — Container engine to use [default: docker] + + Possible values: + - `docker`: Docker, or any Docker-compatible CLI + - `apple-container`: Apple's `container` CLI (macOS 26+, Apple silicon) + +- `--cpus ` — Limit the number of CPUs available to the container, e.g. `2`. A whole number: Apple's `container` engine does not accept fractional CPUs +- `--memory ` — Limit the memory available to the container, e.g. `2g` or `512m` + ###### **Features:** - `--features ` — Build with the list of features activated, space or comma separated @@ -407,13 +419,12 @@ To view the commands that will be executed, without executing them, use the --pr - `--print-commands-only` — Print commands to build without executing them -###### **Verifiable:** +###### **Verifiable Options:** - `--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` -- `-d`, `--docker-host ` — Override the default docker host used by `--verifiable` ## `stellar contract extend` diff --git a/cmd/soroban-cli/src/commands/container/shared.rs b/cmd/soroban-cli/src/commands/container/shared.rs index d4435ab502..000e9838ce 100644 --- a/cmd/soroban-cli/src/commands/container/shared.rs +++ b/cmd/soroban-cli/src/commands/container/shared.rs @@ -99,6 +99,44 @@ impl Engine { Engine::AppleContainer => stderr.contains("not found"), } } + + /// The `inspect`-family argv (after the engine binary and any host flag) + /// that prints an image's digest metadata: docker's `RepoDigests` Go + /// template vs Apple's `image inspect` JSON (Apple groups image operations + /// under the `image` subcommand and has no `--format` templates). + fn image_inspect_args(self, image: &str) -> Vec<&str> { + match self { + Engine::Docker => vec!["inspect", "--format", "{{index .RepoDigests 0}}", image], + Engine::AppleContainer => vec!["image", "inspect", image], + } + } + + /// Parse the stdout of [`image_inspect_args`] into a content-addressed + /// `@sha256:` reference, or `None` when the engine reports no + /// digest (e.g. a locally-built image never pushed or pulled). + fn parse_repo_digest(self, stdout: &[u8], image: &str) -> Option { + match self { + Engine::Docker => { + let digest = String::from_utf8_lossy(stdout).trim().to_string(); + (!digest.is_empty() && digest != "").then_some(digest) + } + // Apple emits a JSON array whose first entry carries the manifest-list + // descriptor at `configuration.descriptor.digest` — the equivalent of + // docker's `RepoDigests`. The per-platform `variants[].digest` is + // deliberately not used. `None` if the output doesn't have that shape. + Engine::AppleContainer => { + let value: serde_json::Value = serde_json::from_slice(stdout).ok()?; + let digest = value + .as_array()? + .first()? + .get("configuration")? + .get("descriptor")? + .get("digest")? + .as_str()?; + Some(format!("{}@{digest}", repo_of(image))) + } + } + } } impl fmt::Display for Engine { @@ -113,7 +151,7 @@ impl fmt::Display for Engine { } } -#[derive(Debug, clap::Parser, Clone)] +#[derive(Debug, clap::Parser, Clone, Default)] pub struct Args { /// 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 #[arg(short = 'd', long, help = DOCKER_HOST_HELP, env = "DOCKER_HOST")] @@ -294,6 +332,46 @@ impl Args { }) } } + + /// The engine's executable name (`docker`, `container`), for rendering + /// copy-pasteable reproduce commands that name the same binary the CLI ran. + pub(crate) fn program(&self) -> &'static str { + self.engine().program() + } + + /// Resolve a locally-present image to its content-addressed repo digest + /// (`@sha256:`), so a caller can pin the exact bytes rather than a + /// mutable tag. Returns `Ok(None)` when the engine reports no digest (e.g. a + /// locally-built image that was never pushed or pulled). The per-engine + /// `inspect` argv and output parsing live on [`Engine`]; this owns only the + /// command execution (the engine binary and `--docker-host`). + pub(crate) async fn image_repo_digest(&self, image: &str) -> Result, Error> { + let engine = self.engine(); + let output = self + .base_command() + .args(engine.image_inspect_args(image)) + .output() + .await + .map_err(|e| self.io_error(e))?; + if !output.status.success() { + return Ok(None); + } + Ok(engine.parse_repo_digest(&output.stdout, image)) + } +} + +/// The repo portion of an image reference: everything before the `:tag` (or +/// `@digest`). A `:` only separates a tag when it appears after the last `/`, so +/// a registry host's `:port` (e.g. `localhost:5000/foo`) is preserved. +fn repo_of(image: &str) -> &str { + if let Some((repo, _)) = image.split_once('@') { + return repo; + } + let last_slash = image.rfind('/').map_or(0, |i| i + 1); + match image[last_slash..].find(':') { + Some(colon) => &image[..last_slash + colon], + None => image, + } } /// Resource limits for commands that *run* a container (e.g. `container start`). @@ -545,6 +623,63 @@ mod test { assert!(!apple.is_container_not_found("some unrelated failure")); } + #[test] + fn program_matches_engine_binary() { + assert_eq!(args(None, None).program(), "docker"); + assert_eq!( + args(None, Some(Engine::AppleContainer)).program(), + "container" + ); + } + + #[test] + fn repo_of_strips_tag_but_keeps_registry_port() { + assert_eq!( + repo_of("docker.io/stellar/stellar-cli:26.1.0-rust1.90.0"), + "docker.io/stellar/stellar-cli" + ); + assert_eq!(repo_of("localhost:5000/foo:bar"), "localhost:5000/foo"); + assert_eq!(repo_of("localhost:5000/foo"), "localhost:5000/foo"); + // An already digest-pinned ref keeps its repo. + assert_eq!( + repo_of(&format!( + "docker.io/stellar/stellar-cli@sha256:{}", + "a".repeat(64) + )), + "docker.io/stellar/stellar-cli" + ); + } + + #[test] + fn apple_repo_digest_reads_manifest_list_descriptor() { + // Shape mirrors real `container image inspect` output: the top-level + // manifest-list digest lives at [0].configuration.descriptor.digest, + // while the per-platform digest under variants[] must be ignored. + let list = format!("sha256:{}", "8d".repeat(32)); + let variant = format!("sha256:{}", "85".repeat(32)); + let json = format!( + r#"[{{"configuration":{{"descriptor":{{"digest":"{list}","mediaType":"application/vnd.docker.distribution.manifest.list.v2+json","size":743}},"name":"docker.io/stellar/quickstart:latest"}},"id":"8ddf","variants":[{{"digest":"{variant}","platform":{{"architecture":"arm64","os":"linux"}}}}]}}]"# + ); + assert_eq!( + Engine::AppleContainer + .parse_repo_digest(json.as_bytes(), "docker.io/stellar/quickstart:latest"), + Some(format!("docker.io/stellar/quickstart@{list}")) + ); + } + + #[test] + fn docker_parse_repo_digest_trims_and_rejects_no_value() { + assert_eq!( + Engine::Docker.parse_repo_digest(b" docker.io/stellar/cli@sha256:abc\n", "ignored"), + Some("docker.io/stellar/cli@sha256:abc".to_string()) + ); + assert_eq!( + Engine::Docker.parse_repo_digest(b"\n", "ignored"), + None + ); + assert_eq!(Engine::Docker.parse_repo_digest(b" \n", "ignored"), None); + } + #[test] fn run_args_flags_emit_only_set_limits() { assert!(RunArgs::default().flags().is_empty()); @@ -565,4 +700,16 @@ mod test { ["--cpus", "2", "--memory", "2g"] ); } + + #[test] + fn apple_repo_digest_none_when_shape_unexpected() { + let apple = Engine::AppleContainer; + assert_eq!(apple.parse_repo_digest(b"[]", "foo:bar"), None); + assert_eq!(apple.parse_repo_digest(b"not json", "foo:bar"), None); + // Missing the configuration.descriptor.digest path. + assert_eq!( + apple.parse_repo_digest(br#"[{"id":"8ddf"}]"#, "foo:bar"), + None + ); + } } diff --git a/cmd/soroban-cli/src/commands/contract/build.rs b/cmd/soroban-cli/src/commands/contract/build.rs index 56178b4742..b52bf057ed 100644 --- a/cmd/soroban-cli/src/commands/contract/build.rs +++ b/cmd/soroban-cli/src/commands/contract/build.rs @@ -20,7 +20,10 @@ use stellar_xdr::{Limited, Limits, ScMetaEntry, ScMetaV0, StringM, WriteXdr}; #[cfg(feature = "additional-libs")] use crate::commands::contract::optimize; use crate::{ - commands::{global, version}, + commands::{ + container::shared::{Args as ContainerArgs, RunArgs as ContainerRunArgs}, + global, version, HEADING_CONTAINER, HEADING_VERIFIABLE, + }, print::Print, wasm, }; @@ -103,13 +106,13 @@ pub struct Cmd { /// (`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. - #[arg(long, help_heading = "Verifiable")] + #[arg(long, help_heading = HEADING_VERIFIABLE)] pub verifiable: bool, /// 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. - #[arg(long, requires = "verifiable", help_heading = "Verifiable")] + #[arg(long, requires = "verifiable", help_heading = HEADING_VERIFIABLE)] pub image: Option, /// SEP-58 source identification: SHA-256 of the source archive @@ -117,7 +120,7 @@ pub struct Cmd { /// `--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. - #[arg(long, requires = "verifiable", help_heading = "Verifiable")] + #[arg(long, requires = "verifiable", help_heading = HEADING_VERIFIABLE)] pub source_sha256: Option, /// SEP-58 source identification: URI where the source can be obtained, e.g. @@ -127,16 +130,24 @@ pub struct Cmd { long, requires = "verifiable", requires = "source_sha256", - help_heading = "Verifiable" + help_heading = HEADING_VERIFIABLE )] pub source_uri: Option, - /// Override the default docker host used by `--verifiable`. - #[arg(short = 'd', long, env = "DOCKER_HOST", help_heading = "Verifiable")] - pub docker_host: Option, - #[command(flatten)] pub build_args: BuildArgs, + + // Declared last so their `next_help_heading` groups them under the Container + // heading without leaking it onto the ungrouped `build_args` flags above. + /// Container connection options (`--engine`, `--docker-host`) used by + /// `--verifiable`. `--docker-host` is honored only by the docker engine. + #[command(flatten, next_help_heading = HEADING_CONTAINER)] + pub container_args: ContainerArgs, + + /// Container resource limits (`--cpus`, `--memory`) applied to the + /// `--verifiable` build container. + #[command(flatten, next_help_heading = HEADING_CONTAINER)] + pub run_args: ContainerRunArgs, } /// Shared build options for meta and optimization, reused by deploy and upload. @@ -313,7 +324,8 @@ impl Default for Cmd { image: None, source_sha256: None, source_uri: None, - docker_host: None, + container_args: ContainerArgs::default(), + run_args: ContainerRunArgs::default(), build_args: BuildArgs::default(), } } diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 7ca08ec649..25077a9d68 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -138,15 +138,13 @@ pub async fn run( source_sha256: Some(resolved.source_sha256.clone()), }; - // Stage 3: docker. Every docker interaction shells out to the container - // engine through this `Args` (honoring `--docker-host`). Verifiable builds - // pin the engine to docker (`engine: None` → the default): the probes, - // `inspect`, and reproduce lines below are docker-specific, and SEP-58 - // reproducibility depends on that exact toolchain. - let docker = shared::Args { - docker_host: cmd.docker_host.clone(), - engine: None, - }; + // Stage 3: the container engine. Every interaction shells out through these + // `container_args`, which select the engine binary (`--engine`/ + // `STELLAR_CONTAINER_ENGINE`, default docker) and honor `--docker-host` where + // the engine supports it; `run_args` carries the build container's resource + // limits. + let docker = cmd.container_args.clone(); + docker.warn_if_host_ignored(print); let image_ref = resolve_image(cmd, &docker, print).await?; // Only probe the container's cli version when we need to pick between @@ -221,6 +219,7 @@ pub async fn run( &container_cmds, &env, &docker, + &cmd.run_args, print, true, ) @@ -580,27 +579,12 @@ pub async fn resolve_image( Err(e) => return Err(Error::DockerConnection(e)), } - image_repo_digest(docker, &tag).await -} - -/// Resolve a locally-present image to its content-addressed repo digest -/// (`@sha256:`) via `docker inspect`, so the recorded `bldimg` pins -/// the exact bytes that were pulled rather than a mutable tag. -async fn image_repo_digest(docker: &shared::Args, tag: &str) -> Result { - let output = docker - .base_command() - .args(["inspect", "--format", "{{index .RepoDigests 0}}", tag]) - .output() - .await - .map_err(|e| docker.io_error(e))?; - - let digest = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !output.status.success() || digest.is_empty() || digest == "" { - return Err(Error::NoRepoDigest { - tag: tag.to_string(), - }); - } - Ok(digest) + // Pin the mutable tag to the content-addressed digest the engine resolved, + // so the recorded `bldimg` names the exact bytes that were pulled. + docker + .image_repo_digest(&tag) + .await? + .ok_or(Error::NoRepoDigest { tag }) } #[derive(Debug, Clone)] @@ -832,12 +816,14 @@ fn escape_container_args(cmd: &[String]) -> String { .join(" ") } +#[allow(clippy::too_many_arguments)] async fn run_in_container( image_ref: &str, workspace_root: &Path, container_cmds: &[Vec], env: &[String], docker: &shared::Args, + run_args: &shared::RunArgs, print: &Print, verbose: bool, ) -> Result<(), Error> { @@ -863,6 +849,18 @@ async fn run_in_container( env_flags.push_str(&shell_escape::escape(e.as_str().into())); } + // Render the reproduce line against the engine binary the CLI actually ran + // (`docker`, `container`), so a third party can replay the exact build. + let program = docker.program(); + + // Resource limits go right after `--rm`, matching where they're applied to + // the spawned command below, so the reproduce line stays copy-paste faithful. + let mut run_flags = String::new(); + for f in run_args.flags() { + run_flags.push(' '); + run_flags.push_str(&shell_escape::escape(f.into())); + } + // One package → run the image's default `stellar` entrypoint directly, so // `post_image` is just the `contract build …` argv. Several → override the // entrypoint to a shell and chain the builds so they all run in this one @@ -871,14 +869,14 @@ async fn run_in_container( let (entrypoint, post_image, reproduce) = if container_cmds.len() > 1 { let chain = compose_shell_command(container_cmds); let reproduce = format!( - "docker run --rm -v {bind}{env_flags} --entrypoint /bin/sh {image_ref} -c {}", + "{program} run --rm{run_flags} -v {bind}{env_flags} --entrypoint /bin/sh {image_ref} -c {}", shell_escape::escape(chain.clone().into()) ); (Some("/bin/sh"), vec!["-c".to_string(), chain], reproduce) } else { let cmd = container_cmds.first().cloned().unwrap_or_default(); let reproduce = format!( - "docker run --rm -v {bind}{env_flags} {image_ref} {}", + "{program} run --rm{run_flags} -v {bind}{env_flags} {image_ref} {}", escape_container_args(&cmd) ); (None, cmd, reproduce) @@ -892,7 +890,9 @@ async fn run_in_container( } let mut command = docker.base_command(); - command.args(["run", "--rm", "-v", &bind, "-w", "/source"]); + command.args(["run", "--rm"]); + run_args.apply(&mut command); + command.args(["-v", &bind, "-w", "/source"]); for e in &env { command.args(["-e", e]); } diff --git a/cmd/soroban-cli/src/commands/mod.rs b/cmd/soroban-cli/src/commands/mod.rs index 9367463625..2e879e8f0d 100644 --- a/cmd/soroban-cli/src/commands/mod.rs +++ b/cmd/soroban-cli/src/commands/mod.rs @@ -32,6 +32,8 @@ pub const HEADING_ARCHIVE: &str = "Archive Options"; pub const HEADING_GLOBAL: &str = "Global Options"; pub const HEADING_SIGNING: &str = "Signing Options"; pub const HEADING_TRANSACTION: &str = "Transaction Options"; +pub const HEADING_CONTAINER: &str = "Container Options"; +pub const HEADING_VERIFIABLE: &str = "Verifiable Options"; const ABOUT: &str = "Work seamlessly with Stellar accounts, contracts, and assets from the command line. From 601814250968d1b083fc5324630a6bc13108e25b Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 17 Jul 2026 11:38:47 -0300 Subject: [PATCH 25/58] Stop the build container when the build is interrupted. --- .../src/commands/container/shared.rs | 10 +++ .../src/commands/contract/build/verifiable.rs | 79 ++++++++++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/cmd/soroban-cli/src/commands/container/shared.rs b/cmd/soroban-cli/src/commands/container/shared.rs index 000e9838ce..af44909d56 100644 --- a/cmd/soroban-cli/src/commands/container/shared.rs +++ b/cmd/soroban-cli/src/commands/container/shared.rs @@ -270,6 +270,16 @@ impl Args { cmd } + /// Immediately terminate a running container (SIGKILL), with no graceful + /// grace period — unlike `stop`, which waits up to 10s before force-killing. + /// Used to tear down a build container the instant the CLI is interrupted. + /// Both docker and Apple's `container` accept `kill `. + pub(crate) fn kill_command(&self, name: &str) -> Command { + let mut cmd = self.base_command(); + cmd.args(["kill", name]); + cmd + } + pub(crate) fn logs_command(&self, name: &str) -> Command { let mut cmd = self.base_command(); match self.engine() { diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 25077a9d68..9baea2cdf3 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -79,6 +79,9 @@ pub enum Error { #[error("container build exited with status {status}. To reproduce manually:\n {command}")] ContainerExit { status: i64, command: String }, + + #[error("verifiable build interrupted; stopped the build container")] + Interrupted, } pub async fn run( @@ -816,6 +819,48 @@ fn escape_container_args(cmd: &[String]) -> String { .join(" ") } +/// Resolve once the process receives any catchable signal that would otherwise +/// terminate it, so the caller can stop the build container before exiting. +/// `SIGKILL` can't be caught, so a `kill -9` still orphans the container — +/// nothing in-process can prevent that. On non-Unix platforms only Ctrl-C is +/// observable. +#[cfg(unix)] +async fn wait_for_termination_signal() { + use tokio::signal::unix::{signal, SignalKind}; + + // If any handler fails to install we simply never resolve on that signal; + // the build still runs, it just won't self-clean on that particular signal. + let mut sigint = signal(SignalKind::interrupt()); + let mut sigterm = signal(SignalKind::terminate()); + let mut sighup = signal(SignalKind::hangup()); + let mut sigquit = signal(SignalKind::quit()); + + tokio::select! { + () = recv_signal(&mut sigint) => {}, + () = recv_signal(&mut sigterm) => {}, + () = recv_signal(&mut sighup) => {}, + () = recv_signal(&mut sigquit) => {}, + } +} + +/// Await one delivery of an installed signal. When the handler failed to +/// install, never resolves, so it drops out of the `select!` above rather than +/// firing spuriously. +#[cfg(unix)] +async fn recv_signal(s: &mut std::io::Result) { + match s { + Ok(s) => { + s.recv().await; + } + Err(_) => std::future::pending().await, + } +} + +#[cfg(not(unix))] +async fn wait_for_termination_signal() { + let _ = tokio::signal::ctrl_c().await; +} + #[allow(clippy::too_many_arguments)] async fn run_in_container( image_ref: &str, @@ -889,8 +934,22 @@ async fn run_in_container( print.infoln(format!("Running: {reproduce}")); } + // Name the build container so it can be stopped if the CLI is interrupted. + // Without a name there's no handle to target: on a termination signal the + // CLI process dies, but the container the daemon owns keeps running (the + // engine client exiting doesn't stop it, and signal-forwarding through the + // client is unreliable for a long cargo build). `--rm` removes it once + // stopped. The name is unique per invocation (pid + random) so concurrent + // builds don't collide, and it's kept out of the reproduce line — a fixed + // name there would clash on re-run. + let container_name = format!( + "stellar-verifiable-build-{}-{:08x}", + std::process::id(), + rand::random::() + ); + let mut command = docker.base_command(); - command.args(["run", "--rm"]); + command.args(["run", "--rm", "--name", &container_name]); run_args.apply(&mut command); command.args(["-v", &bind, "-w", "/source"]); for e in &env { @@ -913,7 +972,23 @@ async fn run_in_container( }; command.stdout(stdout).stderr(stderr); - let status = command.status().await.map_err(|e| docker.io_error(e))?; + let mut child = command.spawn().map_err(|e| docker.io_error(e))?; + + // Race the build against any catchable termination signal. On a signal, + // stop the named container (best-effort) so it doesn't outlive the CLI, + // kill the engine client we spawned, then surface the interruption. + let status = tokio::select! { + result = child.wait() => result.map_err(|e| docker.io_error(e))?, + () = wait_for_termination_signal() => { + print.warnln("Interrupted; stopping build container"); + // `kill` (SIGKILL, immediate) rather than `stop` (SIGTERM + 10s + // grace): otherwise the container keeps building for the whole grace + // period while we block here. + let _ = docker.kill_command(&container_name).output().await; + let _ = child.start_kill(); + return Err(Error::Interrupted); + } + }; if !status.success() { return Err(Error::ContainerExit { status: status.code().unwrap_or(-1).into(), From e182c7e3de2f578768131d8445db21bf0a8fa7dc Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 16:53:23 -0700 Subject: [PATCH 26/58] 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 f5b1b7dab5..3068a7f562 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -99,6 +99,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` @@ -1162,6 +1163,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 9baea2cdf3..1fee860a2d 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -350,7 +350,7 @@ fn resolve_archive(cmd: &Cmd, source_root: &Path, print: &Print) -> Result 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 ee140be938..b7fcb58bba 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 0000000000..2ed91e7c8e --- /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 2df7217f48c3ee7284bc7e443c73dfdd98e465b7 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 17:04:26 -0700 Subject: [PATCH 27/58] 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 2ed91e7c8e..af2ffbda66 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 8ad2ff88bb0f8da26b61d0b5dae06ceb2c36f55f Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 17:10:40 -0700 Subject: [PATCH 28/58] Materialize source for stellar contract verify. --- cmd/soroban-cli/Cargo.toml | 3 +- .../src/commands/contract/build/verifiable.rs | 4 +- .../src/commands/contract/verify.rs | 408 +++++++++++------- 3 files changed, 251 insertions(+), 164 deletions(-) diff --git a/cmd/soroban-cli/Cargo.toml b/cmd/soroban-cli/Cargo.toml index 9cf393047c..d08065ae36 100644 --- a/cmd/soroban-cli/Cargo.toml +++ b/cmd/soroban-cli/Cargo.toml @@ -114,6 +114,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"] } @@ -128,8 +129,8 @@ 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" ignore = "0.4.26" +walkdir = "2.5.0" [build-dependencies] crate-git-revision = "0.0.9" diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index 1fee860a2d..21584d4941 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -355,11 +355,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 af2ffbda66..616b843725 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 5164cf2fea84c6d9d743d2aebd69ef8f7d9c8322 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 17:16:43 -0700 Subject: [PATCH 29/58] Rebuild and byte-compare in stellar contract verify. --- Cargo.lock | 1 + FULL_HELP_DOCS.md | 1 + .../src/commands/contract/build/verifiable.rs | 4 +- .../src/commands/contract/verify.rs | 306 +++++++++++++++++- 4 files changed, 304 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aba5091f09..48a80191f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5465,6 +5465,7 @@ dependencies = [ "tracing-subscriber", "ulid", "url", + "walkdir", "wasm-gen", "wasm-opt", "wasmparser 0.116.1", diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index 3068a7f562..6106b81fd0 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -1179,6 +1179,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 21584d4941..d5f226b098 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); @@ -862,7 +862,7 @@ async fn wait_for_termination_signal() { } #[allow(clippy::too_many_arguments)] -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 616b843725..be9078e2d8 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 63d0d331233797c43c4da426e913d805c47390a9 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 17:29:59 -0700 Subject: [PATCH 30/58] 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 6106b81fd0..0853bbb6a9 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -1179,7 +1179,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 be9078e2d8..56ffbe5f49 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 cb21dc9f97d46d7b232b05d580c59e1547705a8d Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 17:34:24 -0700 Subject: [PATCH 31/58] 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 56ffbe5f49..cd789cd85b 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 995a45a78f..e2a1a88864 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 ee1196139d581fe431bbe87cdd79e51462b7b90d Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 17:45:03 -0700 Subject: [PATCH 32/58] 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 cd789cd85b..f3c6182610 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 c0fd99ecd93a598a9db3c4e288c706b2935ee2fe Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 17:47:42 -0700 Subject: [PATCH 33/58] 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 f3c6182610..b5121e35fb 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 525724aa629f1f80b8eab0bec7285ce809c74ca9 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 17:48:32 -0700 Subject: [PATCH 34/58] 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 b5121e35fb..ce13d8bb9b 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 519f4aa96310d6d9d140bcda3afc2031ca602cb0 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 19:43:08 -0700 Subject: [PATCH 35/58] 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 ce13d8bb9b..4e0139b656 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 4894a02f31591852451069019a4c73c217d59ec5 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 20:21:48 -0700 Subject: [PATCH 36/58] 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 4e0139b656..92f9b2df82 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 3f012ee12ebdbb1d36f3e974f11ba322bb624356 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 20:22:45 -0700 Subject: [PATCH 37/58] 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 92f9b2df82..a21de68308 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 f8e39601da6eb2a55bc9a5752585a00bf25a0d66 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 22 May 2026 00:04:09 -0700 Subject: [PATCH 38/58] 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 a21de68308..14361436f5 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 df0ad61f472900b4b069baf68788348280acdf76 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 22 May 2026 00:04:39 -0700 Subject: [PATCH 39/58] 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 9a54b41d31..201c9ba3a8 100644 --- a/cmd/crates/soroban-test/tests/it/main.rs +++ b/cmd/crates/soroban-test/tests/it/main.rs @@ -14,4 +14,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 0000000000..a9a14ec4ea --- /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 872e1c473ce3825ae2721b89da63dd905b416826 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 22 May 2026 00:22:10 -0700 Subject: [PATCH 40/58] Add tarball-sha256 + local --tarball-url verify test. --- Cargo.lock | 2 + 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 +---- 6 files changed, 82 insertions(+), 131 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 48a80191f2..8bae8aaa89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5696,6 +5696,7 @@ dependencies = [ "assert_cmd", "assert_fs", "ed25519-dalek", + "flate2", "fs_extra", "hex", "home", @@ -5715,6 +5716,7 @@ dependencies = [ "stellar-ledger", "stellar-rpc-client", "stellar-strkey 0.0.16", + "tar", "test-case", "testcontainers", "thiserror 1.0.69", diff --git a/Cargo.toml b/Cargo.toml index 676da683f7..8f5f94dce2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,6 +86,8 @@ escape-bytes = "0.1.1" hex = "0.4.3" itertools = "0.10.0" async-trait = "0.1.76" +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 f89fc70668..d7226fcf4d 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 a9a14ec4ea..3337db9958 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 d08065ae36..213076bf26 100644 --- a/cmd/soroban-cli/Cargo.toml +++ b/cmd/soroban-cli/Cargo.toml @@ -113,8 +113,8 @@ rust-embed = { version = "8.2.0", features = ["debug-embed"] } 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 14361436f5..324f0968df 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 1d3902e626413a3525e56af654ee040b8981e262 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 22 May 2026 00:29:59 -0700 Subject: [PATCH 41/58] 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 5e5f4b7a00..a8ba22da21 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 201c9ba3a8..9a54b41d31 100644 --- a/cmd/crates/soroban-test/tests/it/main.rs +++ b/cmd/crates/soroban-test/tests/it/main.rs @@ -14,5 +14,4 @@ mod plugin; mod rpc_provider; mod strkey; mod util; -mod verify; mod version; From 4e21de4182856ef08f8e3cd5ce55bc798bba97d0 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 22 May 2026 01:24:02 -0700 Subject: [PATCH 42/58] 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 324f0968df..1c0c8d3b42 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 de2fc1594e9a7c471788db16f87e5f8b0e1b86a8 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 17 Jun 2026 15:28:12 -0700 Subject: [PATCH 43/58] Share build logic and fix contract verify rebuild. --- Cargo.lock | 2 - 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 +++++++++--------- 5 files changed, 154 insertions(+), 143 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8bae8aaa89..48a80191f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5696,7 +5696,6 @@ dependencies = [ "assert_cmd", "assert_fs", "ed25519-dalek", - "flate2", "fs_extra", "hex", "home", @@ -5716,7 +5715,6 @@ dependencies = [ "stellar-ledger", "stellar-rpc-client", "stellar-strkey 0.0.16", - "tar", "test-case", "testcontainers", "thiserror 1.0.69", diff --git a/cmd/crates/soroban-test/Cargo.toml b/cmd/crates/soroban-test/Cargo.toml index d7226fcf4d..f89fc70668 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 b266cc81c4..0b69f98b52 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 ignore::WalkBuilder; +use crate::config::{data, locator::enforce_hardened_tree}; use crate::print::Print; /// Names that usually shouldn't end up in a source archive — VCS metadata of @@ -74,6 +75,9 @@ pub enum Error { #[error("could not extract source archive: {0}")] ArchiveExtract(std::io::Error), + + #[error(transparent)] + Data(#[from] data::Error), } /// The source tree's root: always the current working directory. The archive is @@ -301,6 +305,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 d5f226b098..c47be14c96 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -12,7 +12,7 @@ use crate::{ container::shared::{self, Error as ConnectionError}, global, }, - config::{data, locator::enforce_hardened_tree}, + config::data, print::Print, }; @@ -258,9 +258,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 @@ -325,23 +325,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 1c0c8d3b42..680a3fd4e6 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 eb6ddd0c3696c9437339c3292b17c36ec9f6d9b8 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 17 Jun 2026 15:37:33 -0700 Subject: [PATCH 44/58] Add --wasm-hash to stellar contract verify. --- FULL_HELP_DOCS.md | 3 +- .../src/commands/contract/verify.rs | 47 +++++++++++++------ 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index 0853bbb6a9..41820062e0 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -1177,7 +1177,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 diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index 680a3fd4e6..65065ee1f6 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 5ffd7acfcf1502fdd870e4f95a4d9a6064525d0d Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 18 Jun 2026 20:58:18 -0700 Subject: [PATCH 45/58] Stop requiring source hash when setting source URI. --- FULL_HELP_DOCS.md | 65 +------------------ cmd/crates/soroban-test/tests/it/build.rs | 30 ++++++++- .../src/commands/contract/build.rs | 13 ++-- 3 files changed, 35 insertions(+), 73 deletions(-) diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index 41820062e0..eac80cf8f8 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -369,18 +369,6 @@ To view the commands that will be executed, without executing them, use the --pr **Usage:** `stellar contract build [OPTIONS]` -###### **Container Options:** - -- `-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 -- `--engine ` — Container engine to use [default: docker] - - Possible values: - - `docker`: Docker, or any Docker-compatible CLI - - `apple-container`: Apple's `container` CLI (macOS 26+, Apple silicon) - -- `--cpus ` — Limit the number of CPUs available to the container, e.g. `2`. A whole number: Apple's `container` engine does not accept fractional CPUs -- `--memory ` — Limit the memory available to the container, e.g. `2g` or `512m` - ###### **Features:** - `--features ` — Build with the list of features activated, space or comma separated @@ -420,12 +408,13 @@ To view the commands that will be executed, without executing them, use the --pr - `--print-commands-only` — Print commands to build without executing them -###### **Verifiable Options:** +###### **Verifiable:** - `--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` @@ -1722,8 +1711,6 @@ Start local networks in containers - `logs` — Get logs from a running network container - `start` — Start a container running a Stellar node, RPC, API, and friendbot (faucet) - `stop` — Stop a network container started with `stellar container start` -- `use` — Set the default container engine used by `stellar container` commands -- `unset` — Unset the default container engine defined previously with `container use ` ## `stellar container logs` @@ -1740,11 +1727,6 @@ Get logs from a running network container ###### **Options:** - `-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 -- `--engine ` — Container engine to use [default: docker] - - Possible values: - - `docker`: Docker, or any Docker-compatible CLI - - `apple-container`: Apple's `container` CLI (macOS 26+, Apple silicon) ## `stellar container start` @@ -1767,14 +1749,6 @@ By default, when starting a testnet container, without any optional arguments, i ###### **Options:** - `-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 -- `--engine ` — Container engine to use [default: docker] - - Possible values: - - `docker`: Docker, or any Docker-compatible CLI - - `apple-container`: Apple's `container` CLI (macOS 26+, Apple silicon) - -- `--cpus ` — Limit the number of CPUs available to the container, e.g. `2`. A whole number: Apple's `container` engine does not accept fractional CPUs -- `--memory ` — Limit the memory available to the container, e.g. `2g` or `512m` - `--name ` — Optional argument to specify the container name - `-l`, `--limits ` — Optional argument to specify the limits for the local network only - `-p`, `--ports-mapping ` — Argument to specify the `HOST_PORT:CONTAINER_PORT` mapping @@ -1799,39 +1773,6 @@ Stop a network container started with `stellar container start` ###### **Options:** - `-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 -- `--engine ` — Container engine to use [default: docker] - - Possible values: - - `docker`: Docker, or any Docker-compatible CLI - - `apple-container`: Apple's `container` CLI (macOS 26+, Apple silicon) - -## `stellar container use` - -Set the default container engine used by `stellar container` commands - -**Usage:** `stellar container use [OPTIONS] ` - -###### **Arguments:** - -- `` — Container engine to use by default - - Possible values: - - `docker`: Docker, or any Docker-compatible CLI - - `apple-container`: Apple's `container` CLI (macOS 26+, Apple silicon) - -###### **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 - -## `stellar container unset` - -Unset the default container engine defined previously with `container use ` - -**Usage:** `stellar container unset [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 ## `stellar config` diff --git a/cmd/crates/soroban-test/tests/it/build.rs b/cmd/crates/soroban-test/tests/it/build.rs index 6d1ba0290c..f636d7fdd5 100644 --- a/cmd/crates/soroban-test/tests/it/build.rs +++ b/cmd/crates/soroban-test/tests/it/build.rs @@ -1319,8 +1319,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() @@ -1328,6 +1326,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 b52bf057ed..dc4f387c0f 100644 --- a/cmd/soroban-cli/src/commands/contract/build.rs +++ b/cmd/soroban-cli/src/commands/contract/build.rs @@ -123,15 +123,10 @@ pub struct Cmd { #[arg(long, requires = "verifiable", help_heading = HEADING_VERIFIABLE)] pub source_sha256: Option, - /// 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 = 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 = HEADING_VERIFIABLE)] pub source_uri: Option, #[command(flatten)] From 54291d5ec962ab013913f834b9456ca34fd6b959 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 8 Jul 2026 11:39:45 -0300 Subject: [PATCH 46/58] Skip --locked when verifying against older images. --- .../src/commands/contract/verify.rs | 48 +++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index 65065ee1f6..cc239a44fd 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -292,7 +292,13 @@ impl Cmd { }; let docker = docker_args.connect_to_docker(&print).await?; verifiable::pull_image(&docker, &meta.bldimg, &print).await?; - let (container_cmd, env) = build_container_command(&meta); + + // `--locked` was only added to `contract build` in cli 25.2.0. The + // recorded bldimg may be older (and still valid), so probe it before + // forcing `--locked` — passing an unknown flag would fail the rebuild. + let supports_locked = + verifiable::probe_supports_locked(&meta.bldimg, &docker, &print).await; + let (container_cmd, env) = build_container_command(&meta, supports_locked); // 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 @@ -613,7 +619,14 @@ fn locate_extracted_source_root(workdir: &Path) -> Result { /// 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, Vec) { +/// +/// `supports_locked`: whether the recorded bldimg's `contract build` accepts +/// `--locked` (added in cli 25.2.0). When false the flag is never injected, so +/// a rebuild against an older image doesn't fail on an unknown argument. +fn build_container_command( + meta: &ExtractedMetadata, + supports_locked: bool, +) -> (Vec, Vec) { let mut forwarded: Vec = Vec::new(); let mut env: Vec = Vec::new(); for o in &meta.bldopts { @@ -639,10 +652,11 @@ fn build_container_command(meta: &ExtractedMetadata) -> (Vec, Vec Date: Wed, 8 Jul 2026 12:15:54 -0300 Subject: [PATCH 47/58] Find rebuilt contract in workspace target folders. --- .../src/commands/contract/verify.rs | 249 ++++++++++++------ 1 file changed, 174 insertions(+), 75 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index cc239a44fd..de356b8fee 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::io::{IsTerminal, Write}; use std::path::{Path, PathBuf}; @@ -6,6 +7,7 @@ use regex::Regex; use sha2::{Digest, Sha256}; use soroban_spec_tools::contract::Spec; use stellar_xdr::{Hash, ScMetaEntry, ScMetaV0}; +use walkdir::WalkDir; use crate::{ commands::{ @@ -305,6 +307,20 @@ impl Cmd { // the build's working tree is that wrapper dir under `workdir`. let source_root = locate_extracted_source_root(workdir.path())?; + // Snapshot any WASM artifacts already present in the materialized source + // *before* the rebuild. A conformant source archive ships no build + // output, so anything here was planted; excluding these from the post- + // build search stops an attacker from smuggling a pre-built binary into + // the tarball to masquerade as the rebuild's output and spoof a match. + let preexisting_wasms: HashSet = + collect_release_wasms(&source_root).into_iter().collect(); + if !preexisting_wasms.is_empty() { + print.warnln(format!( + "Ignoring {} pre-existing WASM artifact(s) in the source; only freshly rebuilt output is trusted", + preexisting_wasms.len() + )); + } + verifiable::run_in_container( &meta.bldimg, &source_root, @@ -318,7 +334,7 @@ impl Cmd { // Locate the rebuilt WASM. The cargo target dir lives under the bind- // mounted /source, which we mapped to `source_root`. - let rebuilt_path = find_rebuilt_wasm(&source_root, &meta)?; + let rebuilt_path = find_rebuilt_wasm(&source_root, &meta, &preexisting_wasms)?; let rebuilt = std::fs::read(&rebuilt_path).map_err(|e| Error::ReadRebuilt { path: rebuilt_path.clone(), source: e, @@ -666,55 +682,55 @@ fn build_container_command( ) } -/// 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 { +/// The two wasm release-output suffixes cargo may write to, newest first. +/// Older toolchains build for `wasm32-unknown-unknown`; current ones use +/// `wasm32v1-none`. The match is deliberately the 2-component `/release` +/// tail rather than `target//release`: cargo's target dir is not fixed +/// at `target/` (it can be relocated via `--target-dir`, `CARGO_TARGET_DIR`, or +/// `build.target-dir`), but the `/release/` layout beneath it is +/// stable. Matching the tail also excludes intermediate artifacts under +/// `release/deps/`, whose parent ends with `release/deps`, not `.../release`. +const WASM_RELEASE_SUFFIXES: [&str; 2] = + ["wasm32v1-none/release", "wasm32-unknown-unknown/release"]; + +/// Walk `root` and return every `*.wasm` sitting directly in a +/// `/release` output directory. The target dir's location is not fixed +/// relative to the crate manifest — in a Cargo workspace it lives at the +/// workspace root, which may be any ancestor of the `--manifest-path` crate — +/// so we search the whole tree rather than guess where it is. +fn collect_release_wasms(root: &Path) -> Vec { + WalkDir::new(root) + .into_iter() + .filter_map(Result::ok) + .map(walkdir::DirEntry::into_path) + .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("wasm")) + .filter(|p| { + p.parent() + .is_some_and(|parent| WASM_RELEASE_SUFFIXES.iter().any(|s| parent.ends_with(s))) + }) + .collect() +} + +/// Locate the WASM produced by the container's rebuild under `source_root`. +/// +/// Only artifacts *created by this rebuild* are eligible: any `*.wasm` present +/// before the build (captured in `preexisting`) is excluded, so a pre-built +/// binary planted in the source archive can't masquerade as the rebuild output. +/// If a `--package=` bldopt was recorded, prefer that file. +fn find_rebuilt_wasm( + source_root: &Path, + meta: &ExtractedMetadata, + preexisting: &HashSet, +) -> Result { let preferred_pkg = meta .bldopts .iter() .find_map(|opt| opt.strip_prefix("--package=").map(|s| s.replace('-', "_"))); - // Cargo's `target/` lives next to the manifest's workspace root, which - // may be a subdirectory of `workdir` when `--manifest-path=…` was - // recorded (e.g. `hello_world/Cargo.toml` in a multi-crate repo). Anchor - // the search at the manifest's parent dir, falling back to `workdir`. - let target_base = meta - .bldopts - .iter() - .find_map(|opt| { - opt.strip_prefix("--manifest-path=") - .and_then(|p| Path::new(p).parent().map(Path::to_path_buf)) - .filter(|p| !p.as_os_str().is_empty()) - }) - .map_or_else(|| workdir.to_path_buf(), |sub| workdir.join(sub)); - - let candidates = [ - target_base.join("target/wasm32v1-none/release"), - target_base.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); - } - } - } + let found: Vec = collect_release_wasms(source_root) + .into_iter() + .filter(|p| !preexisting.contains(p)) + .collect(); if let Some(pkg) = &preferred_pkg { let want = format!("{pkg}.wasm"); @@ -729,11 +745,11 @@ fn find_rebuilt_wasm(workdir: &Path, meta: &ExtractedMetadata) -> Result Err(Error::NoRebuiltWasm { - target: target_base.join("target"), + target: source_root.to_path_buf(), }), 1 => Ok(found.into_iter().next().unwrap()), _ => Err(Error::AmbiguousRebuiltWasm { - target: target_base.join("target"), + target: source_root.to_path_buf(), found: found .iter() .map(|p| p.display().to_string()) @@ -1061,6 +1077,15 @@ mod tests { ); } + fn meta_with_bldopts(bldopts: Vec) -> ExtractedMetadata { + ExtractedMetadata { + bldimg: good_bldimg(), + source_uri: Some("https://github.com/foo/bar".to_string()), + source_sha256: Some("b".repeat(64)), + bldopts, + } + } + #[test] fn find_rebuilt_wasm_picks_single() { let dir = tempfile::TempDir::new().unwrap(); @@ -1068,13 +1093,8 @@ mod tests { 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(); + let meta = meta_with_bldopts(vec![]); + let p = find_rebuilt_wasm(dir.path(), &meta, &HashSet::new()).unwrap(); assert!(p.ends_with("hello.wasm")); } @@ -1086,13 +1106,8 @@ mod tests { 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(); + let meta = meta_with_bldopts(vec!["--package=other-thing".to_string()]); + let p = find_rebuilt_wasm(dir.path(), &meta, &HashSet::new()).unwrap(); assert!(p.ends_with("other_thing.wasm")); } @@ -1104,26 +1119,110 @@ mod tests { 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(); + let meta = meta_with_bldopts(vec![]); + let err = find_rebuilt_wasm(dir.path(), &meta, &HashSet::new()).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(); + let meta = meta_with_bldopts(vec![]); + let err = find_rebuilt_wasm(dir.path(), &meta, &HashSet::new()).unwrap_err(); assert!(matches!(err, Error::NoRebuiltWasm { .. })); } + + #[test] + fn find_rebuilt_wasm_finds_target_at_workspace_root() { + // In a Cargo workspace the `target/` dir sits at the workspace root, not + // next to the crate manifest. The search must still find it when + // `--manifest-path` points deep into a subdirectory (the bug that + // motivated the tree walk). + 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("blocked_message_lib.wasm"), b"x").unwrap(); + std::fs::create_dir_all( + dir.path() + .join("contracts/message-libs/blocked-message-lib/src"), + ) + .unwrap(); + + let meta = meta_with_bldopts(vec![ + "--manifest-path=contracts/message-libs/blocked-message-lib/Cargo.toml".to_string(), + "--package=blocked-message-lib".to_string(), + ]); + let p = find_rebuilt_wasm(dir.path(), &meta, &HashSet::new()).unwrap(); + assert!(p.ends_with("blocked_message_lib.wasm")); + } + + #[test] + fn find_rebuilt_wasm_finds_relocated_target_dir() { + // The output dir need not be named `target/` (e.g. CARGO_TARGET_DIR). + // The `/release/` tail is what's stable, so a renamed dir is + // still found. + let dir = tempfile::TempDir::new().unwrap(); + let release = dir.path().join("custom-out/wasm32-unknown-unknown/release"); + std::fs::create_dir_all(&release).unwrap(); + std::fs::write(release.join("hello.wasm"), b"x").unwrap(); + + let meta = meta_with_bldopts(vec![]); + let p = find_rebuilt_wasm(dir.path(), &meta, &HashSet::new()).unwrap(); + assert!(p.ends_with("hello.wasm")); + } + + #[test] + fn find_rebuilt_wasm_ignores_release_deps_artifacts() { + // Intermediate wasms under `release/deps/` are not the final artifact + // and must not be matched. + let dir = tempfile::TempDir::new().unwrap(); + let release = dir.path().join("target/wasm32v1-none/release"); + let deps = release.join("deps"); + std::fs::create_dir_all(&deps).unwrap(); + std::fs::write(release.join("hello.wasm"), b"x").unwrap(); + std::fs::write(deps.join("hello-abc123.wasm"), b"x").unwrap(); + + let meta = meta_with_bldopts(vec![]); + let p = find_rebuilt_wasm(dir.path(), &meta, &HashSet::new()).unwrap(); + assert!(p.ends_with("hello.wasm")); + } + + #[test] + fn find_rebuilt_wasm_excludes_preexisting_injected_wasm() { + // An attacker ships a pre-built wasm at the output path. It's captured + // in the pre-build snapshot and excluded, so it can't spoof a match — + // leaving no eligible rebuild output. + let dir = tempfile::TempDir::new().unwrap(); + let release = dir.path().join("target/wasm32v1-none/release"); + std::fs::create_dir_all(&release).unwrap(); + let injected = release.join("hello.wasm"); + std::fs::write(&injected, b"x").unwrap(); + + let preexisting: HashSet = collect_release_wasms(dir.path()).into_iter().collect(); + assert!(preexisting.contains(&injected)); + + let meta = meta_with_bldopts(vec![]); + let err = find_rebuilt_wasm(dir.path(), &meta, &preexisting).unwrap_err(); + assert!(matches!(err, Error::NoRebuiltWasm { .. })); + } + + #[test] + fn find_rebuilt_wasm_keeps_freshly_built_alongside_preexisting() { + // A pre-existing wasm is excluded, but a genuinely new one built next to + // it is still found — no false ambiguity. + let dir = tempfile::TempDir::new().unwrap(); + let release = dir.path().join("target/wasm32v1-none/release"); + std::fs::create_dir_all(&release).unwrap(); + let old = release.join("stale.wasm"); + std::fs::write(&old, b"x").unwrap(); + + let preexisting: HashSet = collect_release_wasms(dir.path()).into_iter().collect(); + + // The rebuild then produces a fresh artifact. + std::fs::write(release.join("hello.wasm"), b"x").unwrap(); + + let meta = meta_with_bldopts(vec![]); + let p = find_rebuilt_wasm(dir.path(), &meta, &preexisting).unwrap(); + assert!(p.ends_with("hello.wasm")); + } } From 8a11c08bf2710db9fa3b31a6d6a5612c0e50a411 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 8 Jul 2026 12:25:18 -0300 Subject: [PATCH 48/58] Strip shell quotes from build options when rebuilding. --- .../src/commands/contract/verify.rs | 52 +++++++++++++++---- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index de356b8fee..712a45c6d7 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -646,16 +646,21 @@ fn build_container_command( let mut forwarded: Vec = Vec::new(); let mut env: Vec = Vec::new(); for o in &meta.bldopts { - 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); - } + // Every recorded bldopt is shell-escaped at the source (see + // `build_forwarded_args` in verifiable.rs) so it's valid shell on its + // own — e.g. `--meta=source_repo='github:foo'` or `--env=B='a b'`. The + // single-package rebuild hands argv straight to `stellar` with no shell, + // so unescape each bldopt back to the one raw argv token the original + // build used; otherwise the quoting leaks into the value (a quoted + // `--meta` value even shifts the WASM's byte size via XDR alignment). + let token = shlex::split(o) + .and_then(|mut v| (v.len() == 1).then(|| v.remove(0))) + .unwrap_or_else(|| o.clone()); + if let Some(kv) = token.strip_prefix("--env=") { + // Applied via docker `-e` as a raw `NAME=VALUE`, never forwarded. + env.push(kv.to_string()); } else { - forwarded.push(o.clone()); + forwarded.push(token); } } @@ -1041,6 +1046,35 @@ mod tests { .any(|w| w[0] == "--meta" && w[1] == "bldopt=--env=A=1")); } + #[test] + fn build_container_command_unescapes_quoted_meta_bldopt() { + // Recorded bldopts are shell-escaped at the source, so a `--meta` value + // with a `:` (or spaces) is stored quoted. Verify must unescape it back + // to the raw argv token — otherwise the literal quotes leak into the + // meta value and the rebuilt WASM differs from the original. + 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=source_repo='github:LayerZero-Labs/monorepo-external'".to_string(), + ], + }; + let (cmd, _env) = build_container_command(&meta, true); + // The forwarded build flag is unescaped (no literal quotes reach clap). + assert!( + cmd.contains(&"--meta=source_repo=github:LayerZero-Labs/monorepo-external".to_string()), + "quotes must be stripped from the forwarded --meta, got {cmd:?}" + ); + // The re-recorded `bldopt=` meta keeps the original escaped form verbatim + // so the rebuilt WASM's bldopt entry matches the original byte-for-byte. + assert!( + cmd.windows(2).any(|w| w[0] == "--meta" + && w[1] == "bldopt=--meta=source_repo='github:LayerZero-Labs/monorepo-external'"), + "the bldopt meta must round-trip the escaped original, got {cmd:?}" + ); + } + #[test] fn build_container_command_injects_locked_when_missing() { // A non-conformant origin might not have --locked in bldopts. Verify From 557918a39ce2a8625f254839b170be8212d61d42 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 8 Jul 2026 12:28:43 -0300 Subject: [PATCH 49/58] Add --keep to save build files for debugging. --- FULL_HELP_DOCS.md | 1 + .../src/commands/contract/verify.rs | 56 +++++++++++++++---- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index eac80cf8f8..facb71bb91 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -1170,6 +1170,7 @@ Verify that a contract's WASM reproduces from the build metadata it records, per - `--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 +- `--keep` — Keep the materialized source and rebuild output instead of deleting them on exit, and print the path. Useful for debugging a byte mismatch (e.g. diffing the rebuilt WASM's metadata against the original) ###### **RPC Options:** diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index 712a45c6d7..a73ae92d17 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -58,6 +58,12 @@ pub struct Cmd { #[arg(short = 'd', long, env = "DOCKER_HOST")] pub docker_host: Option, + /// Keep the materialized source and rebuild output instead of deleting them + /// on exit, and print the path. Useful for debugging a byte mismatch (e.g. + /// diffing the rebuilt WASM's metadata against the original). + #[arg(long)] + pub keep: bool, + #[command(flatten)] pub locator: locator::Args, @@ -280,32 +286,57 @@ impl Cmd { } // Materialize the recorded source into a tempdir so the rebuild can - // bind-mount it. The TempDir lives across the rebuild + comparison and - // cleans up on drop. + // bind-mount it. Normally the TempDir cleans up on drop; with `--keep` + // we persist it (below) so a mismatch can be inspected afterwards. let workdir = materialize_source(&meta, self.source_uri.as_deref(), &print).await?; print.checkln(format!( "Source materialized at {}", workdir.path().display() )); + let result = self + .rebuild_and_verify(workdir.path(), &meta, &wasm_bytes, global_args, &print) + .await; + + // Persist the build tree when asked — regardless of the outcome, so a + // byte mismatch (or a rebuild error) can be debugged against the kept + // source and rebuilt WASM. Otherwise it cleans up on drop here. + if self.keep { + let kept = workdir.keep(); + Print::new(false).infoln(format!("Kept build directory at {}", kept.display())); + } + + result + } + + /// Rebuild the contract in the recorded `bldimg` and compare the freshly + /// built WASM against the original. Split out from `run` so the caller owns + /// the `TempDir` and can keep or drop it after this returns (see `--keep`). + async fn rebuild_and_verify( + &self, + workdir: &Path, + meta: &ExtractedMetadata, + wasm_bytes: &[u8], + global_args: &global::Args, + print: &Print, + ) -> Result<(), Error> { // Rebuild in the recorded bldimg. 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 docker = docker_args.connect_to_docker(print).await?; + verifiable::pull_image(&docker, &meta.bldimg, print).await?; // `--locked` was only added to `contract build` in cli 25.2.0. The // recorded bldimg may be older (and still valid), so probe it before // forcing `--locked` — passing an unknown flag would fail the rebuild. - let supports_locked = - verifiable::probe_supports_locked(&meta.bldimg, &docker, &print).await; - let (container_cmd, env) = build_container_command(&meta, supports_locked); + let supports_locked = verifiable::probe_supports_locked(&meta.bldimg, &docker, print).await; + let (container_cmd, env) = build_container_command(meta, supports_locked); // 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())?; + let source_root = locate_extracted_source_root(workdir)?; // Snapshot any WASM artifacts already present in the materialized source // *before* the rebuild. A conformant source archive ships no build @@ -327,23 +358,26 @@ impl Cmd { &[container_cmd], &env, &docker, - &print, + print, global_args.verbose || global_args.very_verbose, ) .await?; // Locate the rebuilt WASM. The cargo target dir lives under the bind- // mounted /source, which we mapped to `source_root`. - let rebuilt_path = find_rebuilt_wasm(&source_root, &meta, &preexisting_wasms)?; + let rebuilt_path = find_rebuilt_wasm(&source_root, meta, &preexisting_wasms)?; let rebuilt = std::fs::read(&rebuilt_path).map_err(|e| Error::ReadRebuilt { path: rebuilt_path.clone(), source: e, })?; + if self.keep { + print.infoln(format!("Rebuilt WASM at {}", rebuilt_path.display())); + } // 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 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() { result_print.checkln(format!( From 4d82b0a09f9bd46d865edc5e4bbb59a9ff711c78 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 8 Jul 2026 12:37:54 -0300 Subject: [PATCH 50/58] Show the source file actually used when overriding. --- .../src/commands/contract/verify.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index a73ae92d17..dff64c64ec 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -254,8 +254,22 @@ impl Cmd { let meta = extract_metadata(&wasm_bytes)?; print.infoln(format!("Build image: {}", meta.bldimg)); - if let Some(v) = &meta.source_uri { - print.infoln(format!("Source URI: {v}")); + // Report the source we'll actually fetch from. When `--source-uri` + // overrides the recorded value, show the override (and the recorded + // value it replaces) so the line isn't misleading. + match (&self.source_uri, &meta.source_uri) { + (Some(override_uri), Some(recorded)) => { + print.infoln(format!( + "Source URI: {override_uri} (overrides recorded {recorded})" + )); + } + (Some(override_uri), None) => { + print.infoln(format!("Source URI: {override_uri} (override)")); + } + (None, Some(recorded)) => { + print.infoln(format!("Source URI: {recorded}")); + } + (None, None) => {} } if let Some(v) = &meta.source_sha256 { print.infoln(format!("Source SHA-256: {v}")); From a65557a92b4b12c0948a42db2465b92560875d63 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 9 Jul 2026 15:58:46 -0300 Subject: [PATCH 51/58] Reject unknown source formats. --- .../src/commands/contract/verify.rs | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index dff64c64ec..a747f236d2 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -7,6 +7,7 @@ use regex::Regex; use sha2::{Digest, Sha256}; use soroban_spec_tools::contract::Spec; use stellar_xdr::{Hash, ScMetaEntry, ScMetaV0}; +use url::Url; use walkdir::WalkDir; use crate::{ @@ -121,6 +122,9 @@ pub enum Error { #[error("reading stdin: {0}")] Stdin(std::io::Error), + #[error("source {uri:?} has an unsupported format; accepted formats are {formats}")] + UnsupportedSourceFormat { uri: String, formats: String }, + #[error("the WASM records only `source_sha256` (no `source_uri`). Pass `--source-uri URL_OR_PATH` to provide retrieval.")] SourceUriRequired, @@ -588,6 +592,8 @@ async fn materialize_source( return Err(Error::SourceUriRequired); }; + validate_source_format(&source)?; + print.infoln(format!("Fetching source code from {source}")); let bytes = fetch_tarball_bytes(&source).await?; if let Some(expected) = &meta.source_sha256 { @@ -600,6 +606,45 @@ async fn materialize_source( )?) } +/// Extensions we accept for a source archive: the archive is always a gzipped +/// tarball (see `source_archive`), so only these name it. Checked +/// case-insensitively against the source's basename. +const RECOGNIZED_SOURCE_EXTENSIONS: &[&str] = &[".tar.gz", ".tgz"]; + +/// The last path segment of `source`, whether it's a URL or a local path. Try +/// parsing as a URL first (so query strings and fragments are dropped); if that +/// fails, `source` is a local path, so fall back to `Path::file_name`. +fn source_basename(source: &str) -> String { + if let Ok(url) = Url::parse(source) { + return url + .path_segments() + .and_then(|mut segments| segments.next_back()) + .unwrap_or_default() + .to_string(); + } + Path::new(source) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +/// Reject a `--source-uri` (or recorded `source_uri`) whose basename doesn't end +/// in a recognized archive extension, before we bother fetching it, naming the +/// formats we accept. +fn validate_source_format(source: &str) -> Result<(), Error> { + let basename = source_basename(source).to_ascii_lowercase(); + if RECOGNIZED_SOURCE_EXTENSIONS + .iter() + .any(|ext| basename.ends_with(ext)) + { + return Ok(()); + } + Err(Error::UnsupportedSourceFormat { + uri: source.to_string(), + formats: RECOGNIZED_SOURCE_EXTENSIONS.join(", "), + }) +} + /// 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. @@ -1307,4 +1352,41 @@ mod tests { let p = find_rebuilt_wasm(dir.path(), &meta, &preexisting).unwrap(); assert!(p.ends_with("hello.wasm")); } + + #[test] + fn source_basename_strips_url_query_and_fragment() { + assert_eq!( + source_basename("https://example.com/path/src.tar.gz?token=abc#frag"), + "src.tar.gz" + ); + assert_eq!(source_basename("https://example.com/a/b/x.tgz"), "x.tgz"); + } + + #[test] + fn source_basename_handles_local_paths() { + assert_eq!(source_basename("/tmp/foo/src.tar.gz"), "src.tar.gz"); + assert_eq!(source_basename("./relative/src.tgz"), "src.tgz"); + assert_eq!(source_basename("src.tar.gz"), "src.tar.gz"); + } + + #[test] + fn validate_source_format_accepts_recognized_extensions() { + validate_source_format("https://example.com/src.tar.gz").unwrap(); + validate_source_format("/tmp/src.tgz").unwrap(); + // Case-insensitive. + validate_source_format("SRC.TAR.GZ").unwrap(); + } + + #[test] + fn validate_source_format_rejects_unknown_formats() { + for source in [ + "https://example.com/src.zip", + "/tmp/src.rar", + "src", + "src.gz", + ] { + let err = validate_source_format(source).unwrap_err(); + assert!(matches!(err, Error::UnsupportedSourceFormat { .. })); + } + } } From 0c23e77da72465b983bf63b8500bc9743fb06c40 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 9 Jul 2026 16:21:07 -0300 Subject: [PATCH 52/58] Support zip files as verifiable build sources. --- Cargo.toml | 1 + cmd/soroban-cli/Cargo.toml | 1 + .../commands/contract/build/source_archive.rs | 118 +++++++++++++++++- .../src/commands/contract/build/verifiable.rs | 6 +- .../src/commands/contract/verify.rs | 110 ++++++++-------- 5 files changed, 177 insertions(+), 59 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8f5f94dce2..2f49a06d45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,6 +88,7 @@ itertools = "0.10.0" async-trait = "0.1.76" tar = "0.4.46" flate2 = "1.0.30" +zip = { version = "8.6.0", default-features = false, features = ["deflate"] } serde-aux = "4.1.2" serde_json = "1.0.82" serde = "1.0.82" diff --git a/cmd/soroban-cli/Cargo.toml b/cmd/soroban-cli/Cargo.toml index 213076bf26..a682bc74f8 100644 --- a/cmd/soroban-cli/Cargo.toml +++ b/cmd/soroban-cli/Cargo.toml @@ -115,6 +115,7 @@ futures = "0.3.30" home = "0.5.9" flate2 = { workspace = true } tar = { workspace = true } +zip = { 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/build/source_archive.rs b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs index 0b69f98b52..682e911d3f 100644 --- a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs +++ b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs @@ -76,10 +76,53 @@ pub enum Error { #[error("could not extract source archive: {0}")] ArchiveExtract(std::io::Error), + #[error("could not extract source archive: {0}")] + ZipExtract(zip::result::ZipError), + #[error(transparent)] Data(#[from] data::Error), } +/// Container formats we can extract a source tree from. This only concerns how +/// the tree is packed for transport; the tree itself is always wrapped in a +/// single top-level directory (SEP-58), which callers check after extraction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ArchiveFormat { + /// Gzipped tarball — what `build --verifiable` produces. + TarGz, + /// Zip archive. + Zip, +} + +/// Recognized archive extensions and the format each maps to, matched +/// case-insensitively as a suffix of the archive's filename. Single source of +/// truth for both format detection and the "accepted formats" error text. +const ARCHIVE_EXTENSIONS: &[(&str, ArchiveFormat)] = &[ + (".tar.gz", ArchiveFormat::TarGz), + (".tgz", ArchiveFormat::TarGz), + (".zip", ArchiveFormat::Zip), +]; + +impl ArchiveFormat { + /// The format named by `filename`'s extension, or `None` if unrecognized. + pub(crate) fn from_filename(filename: &str) -> Option { + let lower = filename.to_ascii_lowercase(); + ARCHIVE_EXTENSIONS + .iter() + .find(|(ext, _)| lower.ends_with(ext)) + .map(|(_, format)| *format) + } + + /// Comma-separated list of accepted extensions, for error messages. + pub(crate) fn recognized_extensions() -> String { + ARCHIVE_EXTENSIONS + .iter() + .map(|(ext, _)| *ext) + .collect::>() + .join(", ") + } +} + /// The source tree's root: always the current working directory. The archive is /// rooted there as-is — we do NOT search upward for a git repository or anchor on /// `--manifest-path`'s directory, since for a workspace member the build needs @@ -305,11 +348,20 @@ 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. +/// Unpack a zip archive into `dest`. `ZipArchive::extract` sanitizes each +/// entry's path (dropping anything that would escape `dest`), so a hostile +/// archive can't write outside the tempdir. +pub(crate) fn unpack_zip(bytes: &[u8], dest: &Path) -> Result<(), Error> { + zip::ZipArchive::new(std::io::Cursor::new(bytes)) + .and_then(|mut archive| archive.extract(dest)) + .map_err(Error::ZipExtract) +} + +/// Create a fresh temp directory, unpack the source archive `bytes` (in the +/// given `format`) 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, @@ -321,6 +373,7 @@ pub(crate) fn unpack_targz(bytes: &[u8], dest: &Path) -> Result<(), Error> { pub(crate) fn extract_into_hardened_tempdir( bytes: &[u8], prefix: &str, + format: ArchiveFormat, ) -> Result { let base = data::data_local_dir()?.join("tmp"); std::fs::create_dir_all(&base).map_err(|source| Error::ArchiveWrite { @@ -331,7 +384,10 @@ pub(crate) fn extract_into_hardened_tempdir( .prefix(prefix) .tempdir_in(&base) .map_err(Error::ArchiveExtract)?; - unpack_targz(bytes, tmp.path())?; + match format { + ArchiveFormat::TarGz => unpack_targz(bytes, tmp.path())?, + ArchiveFormat::Zip => unpack_zip(bytes, tmp.path())?, + } enforce_hardened_tree(tmp.path()).map_err(Error::ArchiveExtract)?; Ok(tmp) } @@ -342,6 +398,56 @@ mod tests { use crate::config::locator::enforce_hardened_tree; use sha2::{Digest, Sha256}; + #[test] + fn archive_format_from_filename() { + assert_eq!( + ArchiveFormat::from_filename("src.tar.gz"), + Some(ArchiveFormat::TarGz) + ); + assert_eq!( + ArchiveFormat::from_filename("SRC.TGZ"), + Some(ArchiveFormat::TarGz) + ); + assert_eq!( + ArchiveFormat::from_filename("src.zip"), + Some(ArchiveFormat::Zip) + ); + assert_eq!(ArchiveFormat::from_filename("src.rar"), None); + assert_eq!(ArchiveFormat::from_filename("src"), None); + // The listed extensions are exactly what the error surfaces. + assert_eq!( + ArchiveFormat::recognized_extensions(), + ".tar.gz, .tgz, .zip" + ); + } + + #[test] + fn unpack_zip_round_trips() { + use std::io::Write; + // Build a small zip with a single top-level `source/` dir. + let mut buf = Vec::new(); + { + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf)); + let opts: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default(); + zip.start_file("source/Cargo.toml", opts).unwrap(); + zip.write_all(b"# crate").unwrap(); + zip.start_file("source/src/lib.rs", opts).unwrap(); + zip.write_all(b"// code").unwrap(); + zip.finish().unwrap(); + } + + let dest = tempfile::TempDir::new().unwrap(); + unpack_zip(&buf, dest.path()).unwrap(); + assert_eq!( + std::fs::read(dest.path().join("source/Cargo.toml")).unwrap(), + b"# crate" + ); + assert_eq!( + std::fs::read(dest.path().join("source/src/lib.rs")).unwrap(), + b"// code" + ); + } + #[test] fn is_warned_matches_names_and_dotted_suffixes() { use std::ffi::OsStr; diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs index c47be14c96..331fd6baa4 100644 --- a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -325,7 +325,11 @@ fn resolve_archive(cmd: &Cmd, source_root: &Path, print: &Print) -> Result, /// 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). + /// default trust list, or when the source is provided as an archive (source + /// archives are never default-trusted). #[arg(long)] pub trust: bool, @@ -187,14 +187,14 @@ pub enum Error { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TrustKind { Bldimg, - Tarball, + SourceArchive, } 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"), + TrustKind::SourceArchive => write!(f, "source archive"), } } } @@ -222,11 +222,11 @@ fn trusted_bldimg_regex() -> Regex { Regex::new(TRUSTED_BLDIMG_REGEX_STR).unwrap() } -/// Pure trust decision; no I/O. Tarball sources are never default-trusted. +/// Pure trust decision; no I/O. Source archives 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, + TrustKind::SourceArchive => false, }; if default_trusted { TrustDecision::Trusted @@ -297,10 +297,10 @@ 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 + // Source archive: trust the URL we will actually fetch from (either the // 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)?; + require_trust(self.trust, TrustKind::SourceArchive, &url, &print)?; } // Materialize the recorded source into a tempdir so the rebuild can @@ -360,7 +360,7 @@ impl Cmd { // *before* the rebuild. A conformant source archive ships no build // output, so anything here was planted; excluding these from the post- // build search stops an attacker from smuggling a pre-built binary into - // the tarball to masquerade as the rebuild's output and spoof a match. + // the archive to masquerade as the rebuild's output and spoof a match. let preexisting_wasms: HashSet = collect_release_wasms(&source_root).into_iter().collect(); if !preexisting_wasms.is_empty() { @@ -413,7 +413,7 @@ impl Cmd { } } - /// The tarball URL we'll actually retrieve from: the cli override if set, + /// The source archive URL we'll actually retrieve from: the cli override if set, /// 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. @@ -546,8 +546,8 @@ fn confirm_interactively(kind: TrustKind, value: &str) -> Result<(), Error> { 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." + TrustKind::SourceArchive => format!( + "Source archive {value} is not trusted by default. Source archives always require confirmation." ), }; print.warnln(context); @@ -584,18 +584,18 @@ async fn materialize_source( source_uri_override: Option<&str>, print: &Print, ) -> Result { - let tarball_source = source_uri_override + let resolved_source = source_uri_override .map(str::to_string) .or_else(|| meta.source_uri.clone()); - let Some(source) = tarball_source else { + let Some(source) = resolved_source else { // No source_uri anywhere — only source_sha256 is set. return Err(Error::SourceUriRequired); }; - validate_source_format(&source)?; + let format = resolve_source_format(&source)?; print.infoln(format!("Fetching source code from {source}")); - let bytes = fetch_tarball_bytes(&source).await?; + let bytes = fetch_source_bytes(&source).await?; if let Some(expected) = &meta.source_sha256 { verify_source_sha256(&bytes, expected)?; print.checkln("Source SHA-256 matches"); @@ -603,14 +603,10 @@ async fn materialize_source( Ok(source_archive::extract_into_hardened_tempdir( &bytes, "verify-src-", + format, )?) } -/// Extensions we accept for a source archive: the archive is always a gzipped -/// tarball (see `source_archive`), so only these name it. Checked -/// case-insensitively against the source's basename. -const RECOGNIZED_SOURCE_EXTENSIONS: &[&str] = &[".tar.gz", ".tgz"]; - /// The last path segment of `source`, whether it's a URL or a local path. Try /// parsing as a URL first (so query strings and fragments are dropped); if that /// fails, `source` is a local path, so fall back to `Path::file_name`. @@ -628,27 +624,23 @@ fn source_basename(source: &str) -> String { .unwrap_or_default() } -/// Reject a `--source-uri` (or recorded `source_uri`) whose basename doesn't end -/// in a recognized archive extension, before we bother fetching it, naming the -/// formats we accept. -fn validate_source_format(source: &str) -> Result<(), Error> { - let basename = source_basename(source).to_ascii_lowercase(); - if RECOGNIZED_SOURCE_EXTENSIONS - .iter() - .any(|ext| basename.ends_with(ext)) - { - return Ok(()); - } - Err(Error::UnsupportedSourceFormat { - uri: source.to_string(), - formats: RECOGNIZED_SOURCE_EXTENSIONS.join(", "), +/// Determine the archive format from a `--source-uri` (or recorded `source_uri`) +/// by its basename, rejecting sources whose extension we don't recognize before +/// we bother fetching them, naming the formats we accept. +fn resolve_source_format(source: &str) -> Result { + let basename = source_basename(source); + source_archive::ArchiveFormat::from_filename(&basename).ok_or_else(|| { + Error::UnsupportedSourceFormat { + uri: source.to_string(), + formats: source_archive::ArchiveFormat::recognized_extensions(), + } }) } -/// 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 +/// Retrieve the source archive 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> { +async fn fetch_source_bytes(source: &str) -> Result, Error> { if source.starts_with("http://") || source.starts_with("https://") { let resp = reqwest::get(source) .await @@ -902,7 +894,7 @@ mod tests { } #[test] - fn extract_metadata_happy_path_tarball_pair() { + fn extract_metadata_happy_path_source_pair() { let wasm = make_wasm_with_meta(&[ ("bldimg", &good_bldimg()), ("source_uri", "https://example.com/src.tar.gz"), @@ -1018,27 +1010,27 @@ mod tests { } #[test] - fn trust_decision_tarball_always_needs_confirmation() { + fn trust_decision_source_archive_always_needs_confirmation() { assert_eq!( trust_decision( "https://github.com/foo/bar.tar.gz", - TrustKind::Tarball, + TrustKind::SourceArchive, false ), TrustDecision::NeedsConfirmation ); assert_eq!( - trust_decision("/local/foo.tar.gz", TrustKind::Tarball, false), + trust_decision("/local/foo.tar.gz", TrustKind::SourceArchive, false), TrustDecision::NeedsConfirmation ); } #[test] - fn trust_decision_tarball_override_with_trust() { + fn trust_decision_source_archive_override_with_trust() { assert_eq!( trust_decision( "https://github.com/foo/bar.tar.gz", - TrustKind::Tarball, + TrustKind::SourceArchive, true ), TrustDecision::Overridden @@ -1370,22 +1362,36 @@ mod tests { } #[test] - fn validate_source_format_accepts_recognized_extensions() { - validate_source_format("https://example.com/src.tar.gz").unwrap(); - validate_source_format("/tmp/src.tgz").unwrap(); + fn resolve_source_format_accepts_recognized_extensions() { + use source_archive::ArchiveFormat; + assert_eq!( + resolve_source_format("https://example.com/src.tar.gz").unwrap(), + ArchiveFormat::TarGz + ); + assert_eq!( + resolve_source_format("/tmp/src.tgz").unwrap(), + ArchiveFormat::TarGz + ); + assert_eq!( + resolve_source_format("https://example.com/src.zip?token=abc").unwrap(), + ArchiveFormat::Zip + ); // Case-insensitive. - validate_source_format("SRC.TAR.GZ").unwrap(); + assert_eq!( + resolve_source_format("SRC.TAR.GZ").unwrap(), + ArchiveFormat::TarGz + ); } #[test] - fn validate_source_format_rejects_unknown_formats() { + fn resolve_source_format_rejects_unknown_formats() { for source in [ - "https://example.com/src.zip", - "/tmp/src.rar", + "https://example.com/src.rar", + "/tmp/src.7z", "src", "src.gz", ] { - let err = validate_source_format(source).unwrap_err(); + let err = resolve_source_format(source).unwrap_err(); assert!(matches!(err, Error::UnsupportedSourceFormat { .. })); } } From d702bc0865a91e589b4ba5b76544b8350699e0a0 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 9 Jul 2026 17:33:25 -0300 Subject: [PATCH 53/58] Reject duplicate build metadata when verifying. --- .../src/commands/contract/verify.rs | 54 +++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index 31f8057e86..1bd037791f 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -101,6 +101,9 @@ pub enum Error { #[error("the WASM's contractmetav0 does not record a `bldimg` entry; cannot verify")] MissingBldimg, + #[error("the WASM's contractmetav0 records more than one `{field}` entry; refusing to verify (which value applies is ambiguous)")] + DuplicateMeta { field: &'static str }, + #[error("the WASM's contractmetav0 does not record a `source_sha256` entry; cannot verify")] MissingSourceSha256, @@ -457,14 +460,28 @@ pub fn extract_metadata(wasm: &[u8]) -> Result { let mut source_sha256: Option = None; let mut bldopts: Vec = Vec::new(); + // Each of these SEP-58 fields must appear at most once. Reject duplicates + // rather than silently taking the last: two `bldimg` entries (say a benign + // one to fool inspection and a second the cli would actually trust and + // rebuild in) would be a verification-bypass vector, and the same ambiguity + // applies to the `source_uri`/`source_sha256` that pin what gets rebuilt. + let set_once = + |slot: &mut Option, field: &'static str, v: String| -> Result<(), Error> { + if slot.is_some() { + return Err(Error::DuplicateMeta { field }); + } + *slot = Some(v); + Ok(()) + }; + 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_uri" => source_uri = Some(v), - "source_sha256" => source_sha256 = Some(v), + "bldimg" => set_once(&mut bldimg, "bldimg", v)?, + "source_uri" => set_once(&mut source_uri, "source_uri", v)?, + "source_sha256" => set_once(&mut source_sha256, "source_sha256", v)?, "bldopt" => bldopts.push(v), _ => {} // cliver and any user --meta are intentionally ignored } @@ -916,6 +933,37 @@ mod tests { assert!(matches!(err, Error::MissingBldimg)); } + #[test] + fn extract_metadata_duplicate_bldimg_errors() { + // A second bldimg — e.g. a benign one to fool inspection plus one the + // cli would actually trust and rebuild in — must be rejected outright. + let other = format!("docker.io/attacker/evil@sha256:{}", "b".repeat(64)); + let wasm = make_wasm_with_meta(&[ + ("bldimg", &good_bldimg()), + ("bldimg", &other), + ("source_sha256", &"f".repeat(64)), + ]); + let err = extract_metadata(&wasm).unwrap_err(); + assert!(matches!(err, Error::DuplicateMeta { field: "bldimg" })); + } + + #[test] + fn extract_metadata_duplicate_source_ids_error() { + for field in ["source_uri", "source_sha256"] { + let wasm = make_wasm_with_meta(&[ + ("bldimg", &good_bldimg()), + ("source_sha256", &"f".repeat(64)), + (field, "https://example.com/a.tar.gz"), + (field, "https://example.com/b.tar.gz"), + ]); + let err = extract_metadata(&wasm).unwrap_err(); + assert!( + matches!(err, Error::DuplicateMeta { field: f } if f == field), + "expected DuplicateMeta for {field}, got {err:?}" + ); + } + } + #[test] fn extract_metadata_missing_source_id_errors() { let wasm = make_wasm_with_meta(&[("bldimg", &good_bldimg())]); From d72a03968902afa8acece10718a3cd9de7056644 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 9 Jul 2026 18:29:50 -0300 Subject: [PATCH 54/58] Replay recorded metadata in order when verifying. --- .../src/commands/contract/verify.rs | 238 +++++++++++++----- 1 file changed, 171 insertions(+), 67 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index 1bd037791f..90b23b0ddc 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -240,17 +240,33 @@ pub fn trust_decision(value: &str, kind: TrustKind, trust_flag: bool) -> TrustDe } } -/// SEP-58 metadata extracted from a contract's `contractmetav0` section. +/// Meta keys the rebuild regenerates on its own, so verify must not replay them +/// — re-passing one as `--meta` would write it twice and break byte-equality. +/// `cliver` is re-injected by the container's CLI; `rsver`/`rssdkver` are +/// re-embedded by the SDK when the source is recompiled. Everything else is +/// replayed verbatim (see `ExtractedMetadata::meta_entries`). +const REGENERATED_META_KEYS: &[&str] = &["cliver", "rsver", "rssdkver"]; + +/// SEP-58 metadata read 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). +/// Verify reproduces the section by *replaying* what the WASM records rather +/// than reconstructing it from `build`'s ordering rules: `meta_entries` holds +/// every recorded entry, in the exact order it appears in the WASM, so the +/// rebuild's metadata matches byte-for-byte no matter how (or by what tool) the +/// original was produced — including WASMs authored by hand per SEP-58. The +/// entries the rebuild regenerates itself (`REGENERATED_META_KEYS`) are excluded. +/// +/// The typed fields (`bldimg`, `source_uri`, `source_sha256`, `bldopts`) are +/// pulled out of the same entries only to *drive* the rebuild — pick the image, +/// trust-check, fetch the source, and derive the forwarded build flags. They are +/// not re-added to the `--meta` list; the replay of `meta_entries` covers them. #[derive(Debug, Clone)] pub struct ExtractedMetadata { pub bldimg: String, pub source_uri: Option, pub source_sha256: Option, pub bldopts: Vec, + pub meta_entries: Vec<(String, String)>, } impl Cmd { @@ -446,9 +462,12 @@ impl Cmd { } } -/// Walk the WASM's `contractmetav0` entries and pull out the SEP-58 fields we -/// need to drive a rebuild. Errors when `bldimg` or `source_sha256` is absent, -/// since neither has a sensible default. `source_uri` is optional. +/// Walk the WASM's `contractmetav0` entries. Every entry is captured, in order, +/// into `meta_entries` for verbatim replay — except the keys the rebuild +/// regenerates itself (`REGENERATED_META_KEYS`). The SEP-58 fields that drive +/// the rebuild are pulled out of the same walk. 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() { @@ -459,6 +478,7 @@ pub fn extract_metadata(wasm: &[u8]) -> Result { let mut source_uri: Option = None; let mut source_sha256: Option = None; let mut bldopts: Vec = Vec::new(); + let mut meta_entries: Vec<(String, String)> = Vec::new(); // Each of these SEP-58 fields must appear at most once. Reject duplicates // rather than silently taking the last: two `bldimg` entries (say a benign @@ -478,12 +498,24 @@ pub fn extract_metadata(wasm: &[u8]) -> Result { let ScMetaEntry::ScMetaV0(ScMetaV0 { key, val }) = entry; let k = key.to_string(); let v = val.to_string(); + + // Entries the rebuild re-creates on its own are never replayed; passing + // them as `--meta` would duplicate them and break byte-equality. + if REGENERATED_META_KEYS.contains(&k.as_str()) { + continue; + } + + // Record every other entry verbatim, in order, to replay as `--meta`. + // The typed fields below are additionally pulled out to drive the + // rebuild, but the replayed metadata always comes from `meta_entries`. + meta_entries.push((k.clone(), v.clone())); + match k.as_str() { "bldimg" => set_once(&mut bldimg, "bldimg", v)?, "source_uri" => set_once(&mut source_uri, "source_uri", v)?, "source_sha256" => set_once(&mut source_sha256, "source_sha256", v)?, "bldopt" => bldopts.push(v), - _ => {} // cliver and any user --meta are intentionally ignored + _ => {} // user meta: carried in meta_entries for replay } } @@ -524,6 +556,7 @@ pub fn extract_metadata(wasm: &[u8]) -> Result { source_uri, source_sha256, bldopts, + meta_entries, }) } @@ -722,21 +755,25 @@ fn locate_extracted_source_root(workdir: &Path) -> Result { } /// 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. +/// the env vars to apply via docker `-e`. +/// +/// The metadata is *replayed*, not reconstructed: every entry the WASM records +/// (`meta.meta_entries`, already stripped of the keys the rebuild regenerates) +/// is re-emitted as a `--meta key=value` in its original order, so the rebuilt +/// `contractmetav0` mirrors the source WASM regardless of how it was produced. +/// This keeps verify independent of `build`'s ordering rules and lets it verify +/// WASMs authored by hand per SEP-58. /// -/// `--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. +/// The `bldopt` entries additionally drive the *build flags*: each is forwarded +/// as a flag to the inner `contract build`, with two exceptions — +/// - `--env=` bldopts are applied via docker `-e` (as the original build did), +/// not forwarded. They're shell-escaped at the source, so we unescape back +/// to a raw `NAME=VALUE`. +/// - `--meta=` bldopts are NOT forwarded: the metadata they produced is +/// already a standalone entry in `meta_entries` and replayed above, so +/// forwarding them too would write the value twice. /// -/// 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. +/// Both are still re-recorded — via their `bldopt=` entry in `meta_entries`. /// /// `supports_locked`: whether the recorded bldimg's `contract build` accepts /// `--locked` (added in cli 25.2.0). When false the flag is never injected, so @@ -753,40 +790,43 @@ fn build_container_command( // own — e.g. `--meta=source_repo='github:foo'` or `--env=B='a b'`. The // single-package rebuild hands argv straight to `stellar` with no shell, // so unescape each bldopt back to the one raw argv token the original - // build used; otherwise the quoting leaks into the value (a quoted - // `--meta` value even shifts the WASM's byte size via XDR alignment). + // build used; otherwise the quoting leaks into the value. let token = shlex::split(o) .and_then(|mut v| (v.len() == 1).then(|| v.remove(0))) .unwrap_or_else(|| o.clone()); if let Some(kv) = token.strip_prefix("--env=") { // Applied via docker `-e` as a raw `NAME=VALUE`, never forwarded. env.push(kv.to_string()); + } else if token.starts_with("--meta=") { + // The metadata this produced is replayed from `meta_entries`; + // forwarding it as a flag too would write the value twice. } else { forwarded.push(token); } } - // 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); - // When the image supports it, `--locked` is forced — even if the original // somehow lacked it (a non-conformant build) — so the verifier insists on a // locked rebuild and dependency drift can't move bytes underneath us. Older - // images (< cli 25.2.0) reject the flag, so it's omitted there. + // images (< cli 25.2.0) reject the flag, so it's omitted there. Forcing it + // only affects the build (determinism); it isn't recorded as metadata, so it + // doesn't perturb the rebuilt `contractmetav0`. if supports_locked && !forwarded.iter().any(|a| a == "--locked") { forwarded.insert(0, "--locked".to_string()); } - ( - verifiable::compose_container_args(&forwarded, &metadata), - env, - ) + // Replay every recorded meta entry verbatim, in the WASM's own order, so the + // rebuilt section matches the original byte-for-byte. + let mut metadata: Vec = Vec::new(); + for (k, v) in &meta.meta_entries { + metadata.push("--meta".to_string()); + metadata.push(format!("{k}={v}")); + } + + let mut args = vec!["contract".to_string(), "build".to_string()]; + args.extend(forwarded); + args.extend(metadata); + (args, env) } /// The two wasm release-output suffixes cargo may write to, newest first. @@ -1001,17 +1041,30 @@ mod tests { } #[test] - fn extract_metadata_ignores_cliver_and_user_meta() { + fn extract_metadata_captures_user_meta_and_drops_regenerated_keys() { let wasm = make_wasm_with_meta(&[ ("bldimg", &good_bldimg()), ("source_sha256", &"b".repeat(64)), ("cliver", "26.0.0#abcdef"), + ("rsver", "1.93.0"), + ("rssdkver", "23.0.0"), ("home_domain", "fnando.com"), ("author", "alice"), ]); let meta = extract_metadata(&wasm).unwrap(); - // cliver and user meta land in neither bldopts nor source-ids. + // User meta is not a bldopt or source-id, but it IS captured for replay. assert!(meta.bldopts.is_empty()); + // The rebuild regenerates cliver/rsver/rssdkver, so they're excluded; + // every other entry is captured verbatim, in order, for replay. + assert_eq!( + meta.meta_entries, + vec![ + ("bldimg".to_string(), good_bldimg()), + ("source_sha256".to_string(), "b".repeat(64)), + ("home_domain".to_string(), "fnando.com".to_string()), + ("author".to_string(), "alice".to_string()), + ] + ); } #[test] @@ -1123,6 +1176,7 @@ mod tests { source_uri: None, source_sha256: Some("f".repeat(64)), bldopts: Vec::new(), + meta_entries: Vec::new(), }; let print = Print::new(true); let err = materialize_source(&meta, None, &print).await.unwrap_err(); @@ -1130,7 +1184,31 @@ mod tests { } #[test] - fn build_container_command_replays_bldopts_and_re_records_meta() { + fn build_container_command_replays_meta_in_order_and_forwards_build_flags() { + // The recorded entries as they'd appear in the WASM (cliver/rsver/etc. + // already excluded by extract_metadata). build_container_command must + // replay these verbatim, in order, and derive the build flags from the + // `bldopt=` entries. + let meta_entries = vec![ + ("bldimg".to_string(), good_bldimg()), + ( + "source_uri".to_string(), + "https://github.com/foo/bar".to_string(), + ), + ("source_sha256".to_string(), "b".repeat(64)), + ("home_domain".to_string(), "fnando.com".to_string()), + ("bldopt".to_string(), "--locked".to_string()), + ( + "bldopt".to_string(), + "--meta=home_domain=fnando.com".to_string(), + ), + ("bldopt".to_string(), "--optimize".to_string()), + ("bldopt".to_string(), "--env=A=1".to_string()), + ( + "bldopt".to_string(), + "--env=B='this is very nice'".to_string(), + ), + ]; let meta = ExtractedMetadata { bldimg: good_bldimg(), source_uri: Some("https://github.com/foo/bar".to_string()), @@ -1142,17 +1220,22 @@ mod tests { "--env=A=1".to_string(), "--env=B='this is very nice'".to_string(), ], + meta_entries: meta_entries.clone(), }; let (cmd, env) = build_container_command(&meta, true); // Subcommand prefix. assert_eq!(&cmd[..2], &["contract".to_string(), "build".to_string()]); - // Bldopts are forwarded verbatim as flags to the inner `stellar contract build`. + // Build-affecting bldopts are forwarded as flags to the inner build. assert!(cmd.contains(&"--locked".to_string())); - assert!(cmd.contains(&"--meta=home_domain=fnando.com".to_string())); assert!(cmd.contains(&"--optimize".to_string())); + // `--meta=` bldopts are NOT forwarded as flags: the value is replayed as + // its own standalone `home_domain` entry below, so forwarding it too + // would write it twice. + assert!(!cmd.iter().any(|a| a.starts_with("--meta="))); + // `--env=` bldopts are applied via docker `-e` (unescaped), never // forwarded as build flags. assert!(!cmd.iter().any(|a| a.starts_with("--env="))); @@ -1161,30 +1244,26 @@ mod tests { 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) - .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 — 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 + // The `--meta` list is the recorded entries replayed verbatim, in the + // exact order the WASM records them. + let replayed: Vec<(String, String)> = cmd .windows(2) - .any(|w| w[0] == "--meta" && w[1] == "bldopt=--env=A=1")); + .filter(|w| w[0] == "--meta") + .map(|w| { + let (k, v) = w[1].split_once('=').unwrap(); + (k.to_string(), v.to_string()) + }) + .collect(); + assert_eq!(replayed, meta_entries); } #[test] - fn build_container_command_unescapes_quoted_meta_bldopt() { - // Recorded bldopts are shell-escaped at the source, so a `--meta` value - // with a `:` (or spaces) is stored quoted. Verify must unescape it back - // to the raw argv token — otherwise the literal quotes leak into the - // meta value and the rebuilt WASM differs from the original. + fn build_container_command_replays_meta_bldopt_verbatim_without_forwarding() { + // A `--meta=` bldopt is shell-escaped at the source (a value with a `:` + // or spaces is stored quoted). Verify must NOT forward it as a build + // flag — the value reaches the rebuilt WASM through the standalone + // `source_repo` entry — and the `bldopt=` entry itself is replayed + // verbatim so its escaped form round-trips byte-for-byte. let meta = ExtractedMetadata { bldimg: good_bldimg(), source_uri: Some("https://github.com/foo/bar".to_string()), @@ -1192,15 +1271,34 @@ mod tests { bldopts: vec![ "--meta=source_repo='github:LayerZero-Labs/monorepo-external'".to_string(), ], + meta_entries: vec![ + ( + "source_repo".to_string(), + "github:LayerZero-Labs/monorepo-external".to_string(), + ), + ( + "bldopt".to_string(), + "--meta=source_repo='github:LayerZero-Labs/monorepo-external'".to_string(), + ), + ], }; let (cmd, _env) = build_container_command(&meta, true); - // The forwarded build flag is unescaped (no literal quotes reach clap). + + // No `--meta=` bldopt is forwarded as a build flag. assert!( - cmd.contains(&"--meta=source_repo=github:LayerZero-Labs/monorepo-external".to_string()), - "quotes must be stripped from the forwarded --meta, got {cmd:?}" + !cmd.iter().any(|a| a.starts_with("--meta=")), + "--meta bldopts must not be forwarded, got {cmd:?}" ); - // The re-recorded `bldopt=` meta keeps the original escaped form verbatim - // so the rebuilt WASM's bldopt entry matches the original byte-for-byte. + + // The standalone entry carries the unescaped value to the rebuild. + assert!( + cmd.windows(2).any(|w| w[0] == "--meta" + && w[1] == "source_repo=github:LayerZero-Labs/monorepo-external"), + "the standalone meta entry must be replayed, got {cmd:?}" + ); + + // The `bldopt=` entry keeps the original escaped form verbatim so the + // rebuilt WASM's bldopt entry matches the original byte-for-byte. assert!( cmd.windows(2).any(|w| w[0] == "--meta" && w[1] == "bldopt=--meta=source_repo='github:LayerZero-Labs/monorepo-external'"), @@ -1217,6 +1315,10 @@ mod tests { source_uri: Some("https://github.com/foo/bar".to_string()), source_sha256: Some("b".repeat(64)), bldopts: vec!["--meta=author=alice".to_string()], + meta_entries: vec![ + ("author".to_string(), "alice".to_string()), + ("bldopt".to_string(), "--meta=author=alice".to_string()), + ], }; let (cmd, _env) = build_container_command(&meta, true); let locked_count = cmd.iter().filter(|s| *s == "--locked").count(); @@ -1236,6 +1338,7 @@ mod tests { source_uri: Some("https://github.com/foo/bar".to_string()), source_sha256: Some("b".repeat(64)), bldopts: vec!["--optimize".to_string()], + meta_entries: vec![("bldopt".to_string(), "--optimize".to_string())], }; let (cmd, _env) = build_container_command(&meta, false); assert!( @@ -1250,6 +1353,7 @@ mod tests { source_uri: Some("https://github.com/foo/bar".to_string()), source_sha256: Some("b".repeat(64)), bldopts, + meta_entries: Vec::new(), } } From e71431f12d76b56386eb1a0fd2a0d7e8956c6100 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 9 Jul 2026 18:55:08 -0300 Subject: [PATCH 55/58] Skip source metadata when verifying a contract. --- .../src/commands/contract/verify.rs | 242 +++++++++++++----- 1 file changed, 182 insertions(+), 60 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index 90b23b0ddc..c7218439db 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -1,12 +1,11 @@ use std::collections::HashSet; -use std::io::{IsTerminal, Write}; +use std::io::{Cursor, IsTerminal, Write}; use std::path::{Path, PathBuf}; use clap::Parser; use regex::Regex; use sha2::{Digest, Sha256}; -use soroban_spec_tools::contract::Spec; -use stellar_xdr::{Hash, ScMetaEntry, ScMetaV0}; +use stellar_xdr::{Hash, Limited, Limits, ReadXdr, ScMetaEntry, ScMetaV0}; use url::Url; use walkdir::WalkDir; @@ -92,8 +91,8 @@ pub enum Error { #[error(transparent)] Wasm(#[from] wasm::Error), - #[error(transparent)] - SpecTools(#[from] soroban_spec_tools::contract::Error), + #[error("parsing the WASM's contract metadata: {0}")] + MetaParse(String), #[error("the WASM has no contractmetav0 custom section")] NoMeta, @@ -240,21 +239,29 @@ pub fn trust_decision(value: &str, kind: TrustKind, trust_flag: bool) -> TrustDe } } -/// Meta keys the rebuild regenerates on its own, so verify must not replay them -/// — re-passing one as `--meta` would write it twice and break byte-equality. -/// `cliver` is re-injected by the container's CLI; `rsver`/`rssdkver` are -/// re-embedded by the SDK when the source is recompiled. Everything else is -/// replayed verbatim (see `ExtractedMetadata::meta_entries`). +/// Best-effort list of meta keys the rebuild regenerates on its own, used only +/// as a *fallback* when verify can't localize the CLI-injected section (no +/// `cliver` marker — see `extract_metadata`). `cliver` is re-injected by the +/// container's CLI; `rsver`/`rssdkver` are re-embedded by the SDK on recompile. +/// The normal path partitions by custom section instead, which also catches +/// arbitrary source-embedded keys (e.g. a `contractmeta!` `Description`) that +/// no fixed list could enumerate. const REGENERATED_META_KEYS: &[&str] = &["cliver", "rsver", "rssdkver"]; -/// SEP-58 metadata read from a contract's `contractmetav0` section. +/// The `cliver` entry the CLI stamps into the section it injects; its presence +/// marks that section as the CLI-injected one (see `extract_metadata`). +const CLIVER_KEY: &str = "cliver"; + +/// Metadata read from a contract's `contractmetav0` custom sections (SEP-46). /// /// Verify reproduces the section by *replaying* what the WASM records rather /// than reconstructing it from `build`'s ordering rules: `meta_entries` holds -/// every recorded entry, in the exact order it appears in the WASM, so the +/// the CLI-injected entries, in the exact order the WASM records them, so the /// rebuild's metadata matches byte-for-byte no matter how (or by what tool) the -/// original was produced — including WASMs authored by hand per SEP-58. The -/// entries the rebuild regenerates itself (`REGENERATED_META_KEYS`) are excluded. +/// original was produced. The entries the rebuild regenerates itself — the +/// SDK/compile-emitted section (`rsver`, `rssdkver`, and any `contractmeta!` +/// keys such as `Description`) and the CLI's own `cliver` — are excluded, so +/// they aren't written twice. /// /// The typed fields (`bldimg`, `source_uri`, `source_sha256`, `bldopts`) are /// pulled out of the same entries only to *drive* the rebuild — pick the image, @@ -462,29 +469,92 @@ impl Cmd { } } -/// Walk the WASM's `contractmetav0` entries. Every entry is captured, in order, -/// into `meta_entries` for verbatim replay — except the keys the rebuild -/// regenerates itself (`REGENERATED_META_KEYS`). The SEP-58 fields that drive -/// the rebuild are pulled out of the same walk. Errors when `bldimg` or -/// `source_sha256` is absent, since neither has a sensible default; `source_uri` -/// is optional. +/// Read the WASM's `contractmetav0` custom sections *separately*, preserving +/// both the per-section grouping and the entry order within each. `Spec::meta` +/// concatenates every section into one flat list; keeping them apart is what +/// lets verify tell the SDK/compile-emitted metadata (its own section) from the +/// CLI-injected metadata (a separate section appended by `inject_meta`), so it +/// replays only the latter. SEP-46 permits multiple same-named sections and +/// fixes their concatenation order, so this grouping is well-defined. +fn read_meta_sections(wasm: &[u8]) -> Result>, Error> { + let mut sections = Vec::new(); + for payload in wasmparser::Parser::new(0).parse_all(wasm) { + let payload = payload.map_err(|e| Error::MetaParse(e.to_string()))?; + if let wasmparser::Payload::CustomSection(reader) = payload { + if reader.name() == "contractmetav0" { + sections.push(parse_meta_entries(reader.data())?); + } + } + } + Ok(sections) +} + +/// Decode one `contractmetav0` section's XDR into `(key, value)` pairs, in order. +fn parse_meta_entries(data: &[u8]) -> Result, Error> { + let mut read = Limited::new(Cursor::new(data), Limits::none()); + ScMetaEntry::read_xdr_iter(&mut read) + .map(|entry| { + entry.map(|ScMetaEntry::ScMetaV0(ScMetaV0 { key, val })| { + (key.to_string(), val.to_string()) + }) + }) + .collect::, _>>() + .map_err(|e| Error::MetaParse(e.to_string())) +} + +/// Read the WASM's contract metadata and split out what verify must replay from +/// what the rebuild regenerates on its own. +/// +/// The rebuild re-creates the SDK/compile-emitted metadata (`rsver`, `rssdkver`, +/// and any `contractmeta!` keys) by recompiling the source, and the container's +/// CLI re-injects `cliver`. Replaying any of those as `--meta` would write them +/// twice and break byte-equality. `inject_meta` puts `cliver` plus the user's +/// `--meta` into its own `contractmetav0` section, so the section containing +/// `cliver` *is* the CLI-injected set — everything verify must replay, and +/// nothing the rebuild produces for free. We therefore replay that section +/// (minus `cliver`) and ignore the rest. +/// +/// Fallback: a WASM with no `cliver` (an ancient CLI, or one authored by hand +/// per SEP-46) has no marked section to localize, so we replay every entry +/// except the keys we know are regenerated (`REGENERATED_META_KEYS`). +/// +/// 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() { + let sections = read_meta_sections(wasm)?; + if sections.iter().all(Vec::is_empty) { return Err(Error::NoMeta); } + // The CLI-injected section is the one carrying `cliver`; replay it minus + // `cliver`. Absent that marker, fall back to a key-name filter over every + // section. + let cli_section = sections + .iter() + .position(|s| s.iter().any(|(k, _)| k == CLIVER_KEY)); + let meta_entries: Vec<(String, String)> = match cli_section { + Some(i) => sections[i] + .iter() + .filter(|(k, _)| k != CLIVER_KEY) + .cloned() + .collect(), + None => sections + .into_iter() + .flatten() + .filter(|(k, _)| !REGENERATED_META_KEYS.contains(&k.as_str())) + .collect(), + }; + let mut bldimg: Option = None; let mut source_uri: Option = None; let mut source_sha256: Option = None; let mut bldopts: Vec = Vec::new(); - let mut meta_entries: Vec<(String, String)> = Vec::new(); - // Each of these SEP-58 fields must appear at most once. Reject duplicates - // rather than silently taking the last: two `bldimg` entries (say a benign - // one to fool inspection and a second the cli would actually trust and - // rebuild in) would be a verification-bypass vector, and the same ambiguity - // applies to the `source_uri`/`source_sha256` that pin what gets rebuilt. + // Each of these fields must appear at most once. Reject duplicates rather + // than silently taking the last: two `bldimg` entries (say a benign one to + // fool inspection and a second the cli would actually trust and rebuild in) + // would be a verification-bypass vector, and the same ambiguity applies to + // the `source_uri`/`source_sha256` that pin what gets rebuilt. let set_once = |slot: &mut Option, field: &'static str, v: String| -> Result<(), Error> { if slot.is_some() { @@ -494,27 +564,14 @@ pub fn extract_metadata(wasm: &[u8]) -> Result { Ok(()) }; - for entry in &spec.meta { - let ScMetaEntry::ScMetaV0(ScMetaV0 { key, val }) = entry; - let k = key.to_string(); - let v = val.to_string(); - - // Entries the rebuild re-creates on its own are never replayed; passing - // them as `--meta` would duplicate them and break byte-equality. - if REGENERATED_META_KEYS.contains(&k.as_str()) { - continue; - } - - // Record every other entry verbatim, in order, to replay as `--meta`. - // The typed fields below are additionally pulled out to drive the - // rebuild, but the replayed metadata always comes from `meta_entries`. - meta_entries.push((k.clone(), v.clone())); - + // The typed fields are pulled out of the very entries we replay, so the + // rebuild is driven by exactly the metadata that gets re-recorded. + for (k, v) in &meta_entries { match k.as_str() { - "bldimg" => set_once(&mut bldimg, "bldimg", v)?, - "source_uri" => set_once(&mut source_uri, "source_uri", v)?, - "source_sha256" => set_once(&mut source_sha256, "source_sha256", v)?, - "bldopt" => bldopts.push(v), + "bldimg" => set_once(&mut bldimg, "bldimg", v.clone())?, + "source_uri" => set_once(&mut source_uri, "source_uri", v.clone())?, + "source_sha256" => set_once(&mut source_sha256, "source_sha256", v.clone())?, + "bldopt" => bldopts.push(v.clone()), _ => {} // user meta: carried in meta_entries for replay } } @@ -921,9 +978,18 @@ mod tests { use stellar_xdr::{Limited, Limits, ScMetaEntry, ScMetaV0, WriteXdr}; fn make_wasm_with_meta(entries: &[(&str, &str)]) -> Vec { - let xdr = encode_meta(entries); + make_wasm_with_sections(&[entries]) + } + + /// Build a WASM with one `contractmetav0` custom section per slice, in order + /// — mirroring how the SDK/compile step and the CLI's `inject_meta` each + /// append their own section. + fn make_wasm_with_sections(sections: &[&[(&str, &str)]]) -> Vec { let mut wasm = empty_wasm_module(); - wasm_gen::write_custom_section(&mut wasm, "contractmetav0", &xdr); + for entries in sections { + let xdr = encode_meta(entries); + wasm_gen::write_custom_section(&mut wasm, "contractmetav0", &xdr); + } wasm } @@ -1041,32 +1107,88 @@ mod tests { } #[test] - fn extract_metadata_captures_user_meta_and_drops_regenerated_keys() { + fn extract_metadata_replays_only_cli_section() { + // Real layout: the SDK/compile step emits its own section (a + // `contractmeta!` `Description`, plus rsver/rssdkver), then the CLI + // appends a second section holding cliver + the user `--meta`. Verify + // must replay only the CLI section (minus cliver); the source-embedded + // section is regenerated by recompiling, so replaying it would duplicate + // those entries and break byte-equality (the bug this fixes). + let wasm = make_wasm_with_sections(&[ + // SDK / compile-emitted section. + &[ + ("Description", "A hello world contract"), + ("key1", "val1"), + ("key2", "val2"), + ("rsver", "1.96.0"), + ("rssdkver", "26.1.0#abcdef"), + ], + // CLI-injected section. + &[ + ("cliver", "27.0.0#abcdef"), + ("bldimg", &good_bldimg()), + ("source_sha256", &"b".repeat(64)), + ("bldopt", "--locked"), + ("home_domain", "fnando.com"), + ], + ]); + let meta = extract_metadata(&wasm).unwrap(); + assert_eq!(meta.bldopts, vec!["--locked".to_string()]); + // Only the CLI section is replayed, in order, with cliver stripped. + // Nothing from the source-embedded section leaks in. + assert_eq!( + meta.meta_entries, + vec![ + ("bldimg".to_string(), good_bldimg()), + ("source_sha256".to_string(), "b".repeat(64)), + ("bldopt".to_string(), "--locked".to_string()), + ("home_domain".to_string(), "fnando.com".to_string()), + ] + ); + } + + #[test] + fn extract_metadata_fallback_filters_regenerated_keys_without_cliver() { + // No cliver anywhere (ancient CLI or hand-authored per SEP-46): there's + // no marked section to localize, so replay everything except the keys we + // know the rebuild regenerates. let wasm = make_wasm_with_meta(&[ + ("rsver", "1.96.0"), + ("rssdkver", "26.1.0#abcdef"), ("bldimg", &good_bldimg()), ("source_sha256", &"b".repeat(64)), - ("cliver", "26.0.0#abcdef"), - ("rsver", "1.93.0"), - ("rssdkver", "23.0.0"), ("home_domain", "fnando.com"), - ("author", "alice"), ]); let meta = extract_metadata(&wasm).unwrap(); - // User meta is not a bldopt or source-id, but it IS captured for replay. - assert!(meta.bldopts.is_empty()); - // The rebuild regenerates cliver/rsver/rssdkver, so they're excluded; - // every other entry is captured verbatim, in order, for replay. assert_eq!( meta.meta_entries, vec![ ("bldimg".to_string(), good_bldimg()), ("source_sha256".to_string(), "b".repeat(64)), ("home_domain".to_string(), "fnando.com".to_string()), - ("author".to_string(), "alice".to_string()), ] ); } + #[test] + fn extract_metadata_ignores_duplicate_key_in_source_embedded_section() { + // A `contractmeta!` entry that happens to reuse a reserved key (here a + // second `bldimg`) lives in the source-embedded section, which verify + // ignores — so it neither trips duplicate-rejection nor overrides the + // real, CLI-recorded bldimg used to drive (and trust-check) the rebuild. + let evil = format!("docker.io/attacker/evil@sha256:{}", "e".repeat(64)); + let wasm = make_wasm_with_sections(&[ + &[("bldimg", &evil), ("rsver", "1.96.0")], + &[ + ("cliver", "27.0.0#abcdef"), + ("bldimg", &good_bldimg()), + ("source_sha256", &"b".repeat(64)), + ], + ]); + let meta = extract_metadata(&wasm).unwrap(); + assert_eq!(meta.bldimg, good_bldimg()); + } + #[test] fn extract_metadata_empty_meta_errors() { let wasm = empty_wasm_module(); // no contractmetav0 section From da0b601dbca8f2493960193a954a0e549311bcb7 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 9 Jul 2026 19:36:05 -0300 Subject: [PATCH 56/58] Fall back to the last metadata block when verifying. --- .../src/commands/contract/verify.rs | 97 +++++++++++++------ 1 file changed, 65 insertions(+), 32 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index c7218439db..df531c6d6e 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -239,13 +239,14 @@ pub fn trust_decision(value: &str, kind: TrustKind, trust_flag: bool) -> TrustDe } } -/// Best-effort list of meta keys the rebuild regenerates on its own, used only -/// as a *fallback* when verify can't localize the CLI-injected section (no -/// `cliver` marker — see `extract_metadata`). `cliver` is re-injected by the -/// container's CLI; `rsver`/`rssdkver` are re-embedded by the SDK on recompile. -/// The normal path partitions by custom section instead, which also catches -/// arbitrary source-embedded keys (e.g. a `contractmeta!` `Description`) that -/// no fixed list could enumerate. +/// Meta keys the rebuild regenerates on its own, so verify must never replay +/// them — re-passing one would write it twice and break byte-equality. `cliver` +/// is re-injected by the container's CLI; `rsver`/`rssdkver` are re-embedded by +/// the SDK on recompile. The section split in `extract_metadata` already keeps +/// the SDK's own section out; this filter is applied to the chosen section as a +/// final guard (chiefly for a degenerate single-section WASM). Source-embedded +/// keys with arbitrary names (e.g. a `contractmeta!` `Description`) are handled +/// by the section split, which no fixed list could enumerate. const REGENERATED_META_KEYS: &[&str] = &["cliver", "rsver", "rssdkver"]; /// The `cliver` entry the CLI stamps into the section it injects; its presence @@ -514,9 +515,10 @@ fn parse_meta_entries(data: &[u8]) -> Result, Error> { /// nothing the rebuild produces for free. We therefore replay that section /// (minus `cliver`) and ignore the rest. /// -/// Fallback: a WASM with no `cliver` (an ancient CLI, or one authored by hand -/// per SEP-46) has no marked section to localize, so we replay every entry -/// except the keys we know are regenerated (`REGENERATED_META_KEYS`). +/// Fallback: a WASM with no `cliver` (a pre-v23.2.0 CLI never wrote one, and a +/// WASM may be hand-authored per SEP-46) has no marked section, so we take the +/// last non-empty section instead — `inject_meta` always appends after the +/// compile-emitted sections, so the CLI section is always last. /// /// Errors when `bldimg` or `source_sha256` is absent, since neither has a /// sensible default; `source_uri` is optional. @@ -526,24 +528,24 @@ pub fn extract_metadata(wasm: &[u8]) -> Result { return Err(Error::NoMeta); } - // The CLI-injected section is the one carrying `cliver`; replay it minus - // `cliver`. Absent that marker, fall back to a key-name filter over every - // section. + // Locate the CLI-injected section: the one carrying `cliver`, or — when no + // section is marked — the last non-empty one, since `inject_meta` always + // appends after the compile-emitted sections (the linker merges every + // `#[link_section = "contractmetav0"]` static — `contractmeta!` entries plus + // the SDK's `rsver`/`rssdkver` — into a single earlier section). Replay it, + // dropping the keys the rebuild regenerates itself (`REGENERATED_META_KEYS`): + // a well-formed CLI section holds none of them, but this guards a degenerate + // single-section WASM where build fields sit alongside `rsver`/`rssdkver`. let cli_section = sections .iter() - .position(|s| s.iter().any(|(k, _)| k == CLIVER_KEY)); - let meta_entries: Vec<(String, String)> = match cli_section { - Some(i) => sections[i] - .iter() - .filter(|(k, _)| k != CLIVER_KEY) - .cloned() - .collect(), - None => sections - .into_iter() - .flatten() - .filter(|(k, _)| !REGENERATED_META_KEYS.contains(&k.as_str())) - .collect(), - }; + .position(|s| s.iter().any(|(k, _)| k == CLIVER_KEY)) + .or_else(|| sections.iter().rposition(|s| !s.is_empty())) + .expect("a non-empty section exists: the all-empty case is rejected as NoMeta above"); + let meta_entries: Vec<(String, String)> = sections[cli_section] + .iter() + .filter(|(k, _)| !REGENERATED_META_KEYS.contains(&k.as_str())) + .cloned() + .collect(); let mut bldimg: Option = None; let mut source_uri: Option = None; @@ -1148,15 +1150,46 @@ mod tests { } #[test] - fn extract_metadata_fallback_filters_regenerated_keys_without_cliver() { - // No cliver anywhere (ancient CLI or hand-authored per SEP-46): there's - // no marked section to localize, so replay everything except the keys we - // know the rebuild regenerates. + fn extract_metadata_fallback_picks_last_section_without_cliver() { + // Pre-v23.2.0 (or hand-authored per SEP-46): no cliver marker anywhere. + // The build fields were added via plain --meta, so they're in the CLI- + // appended (last) section; the compile-emitted section — contractmeta! + // entries plus rsver/rssdkver — comes first and must be ignored, or its + // key1/key2 would be replayed and duplicated on rebuild. + let wasm = make_wasm_with_sections(&[ + &[ + ("key1", "val1"), + ("key2", "val2"), + ("rsver", "1.97.0"), + ("rssdkver", "22.0.11#abcdef"), + ], + &[ + ("bldimg", &good_bldimg()), + ("source_sha256", &"b".repeat(64)), + ("bldopt", "--locked"), + ], + ]); + let meta = extract_metadata(&wasm).unwrap(); + assert_eq!( + meta.meta_entries, + vec![ + ("bldimg".to_string(), good_bldimg()), + ("source_sha256".to_string(), "b".repeat(64)), + ("bldopt".to_string(), "--locked".to_string()), + ] + ); + } + + #[test] + fn extract_metadata_single_section_fallback_drops_regenerated_keys() { + // Degenerate: only one section, no cliver, build fields embedded next to + // rsver/rssdkver. The last-section fallback picks it, and the guard filter + // still strips rsver/rssdkver so they aren't replayed and duplicated. let wasm = make_wasm_with_meta(&[ - ("rsver", "1.96.0"), - ("rssdkver", "26.1.0#abcdef"), ("bldimg", &good_bldimg()), ("source_sha256", &"b".repeat(64)), + ("rsver", "1.97.0"), + ("rssdkver", "22.0.11#abcdef"), ("home_domain", "fnando.com"), ]); let meta = extract_metadata(&wasm).unwrap(); From 7027bd90f9cfe2766137f5f7eac141335f767074 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 16 Jul 2026 11:38:27 -0300 Subject: [PATCH 57/58] Run contract verify through the docker CLI. --- Cargo.lock | 55 +++++++++++++++++++ FULL_HELP_DOCS.md | 2 +- .../src/commands/contract/verify.rs | 11 ++-- 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 48a80191f2..8342c85a94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2139,6 +2139,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -3431,6 +3432,15 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libz-rs-sys" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415" +dependencies = [ + "zlib-rs", +] + [[package]] name = "link-cplusplus" version = "1.0.12" @@ -5303,6 +5313,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "similar" version = "2.7.0" @@ -5472,6 +5488,7 @@ dependencies = [ "which", "whoami", "zeroize", + "zip", ] [[package]] @@ -6723,6 +6740,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.18.0" @@ -8053,12 +8076,44 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.11.0", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zvariant" version = "4.2.0" diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index facb71bb91..ae4df36e9c 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 - `--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) +- `--trust` — Bypass interactive confirmation when the WASM's bldimg is not in the default trust list, or when the source is provided as an archive (source archives are never default-trusted) - `-d`, `--docker-host ` — Override the default docker host used by the rebuild - `--keep` — Keep the materialized source and rebuild output instead of deleting them on exit, and print the path. Useful for debugging a byte mismatch (e.g. diffing the rebuilt WASM's metadata against the original) diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index df531c6d6e..9dab25128a 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -157,9 +157,6 @@ pub enum Error { #[error(transparent)] Verifiable(#[from] verifiable::Error), - #[error(transparent)] - Bollard(#[from] bollard::errors::Error), - #[error(transparent)] DockerConnection(#[from] container::shared::Error), @@ -365,12 +362,12 @@ impl Cmd { global_args: &global::Args, print: &Print, ) -> Result<(), Error> { - // Rebuild in the recorded bldimg. - let docker_args = container::shared::Args { + // Rebuild in the recorded bldimg. Every docker interaction shells out to + // the `docker` CLI through this `Args` (honoring `--docker-host`). + let docker = 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?; + docker.pull_image(&meta.bldimg, print).await?; // `--locked` was only added to `contract build` in cli 25.2.0. The // recorded bldimg may be older (and still valid), so probe it before From 3f9425880c0bbad6c454c75431cd55357b783dec Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 16 Jul 2026 21:42:41 -0300 Subject: [PATCH 58/58] Support other container engines in contract verify. --- FULL_HELP_DOCS.md | 78 ++++++++++++++++++- .../src/commands/contract/verify.rs | 28 ++++--- 2 files changed, 90 insertions(+), 16 deletions(-) diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index ae4df36e9c..916529a10b 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -369,6 +369,18 @@ To view the commands that will be executed, without executing them, use the --pr **Usage:** `stellar contract build [OPTIONS]` +###### **Container Options:** + +- `-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 +- `--engine ` — Container engine to use [default: docker] + + Possible values: + - `docker`: Docker, or any Docker-compatible CLI + - `apple-container`: Apple's `container` CLI (macOS 26+, Apple silicon) + +- `--cpus ` — Limit the number of CPUs available to the container, e.g. `2`. A whole number: Apple's `container` engine does not accept fractional CPUs +- `--memory ` — Limit the memory available to the container, e.g. `2g` or `512m` + ###### **Features:** - `--features ` — Build with the list of features activated, space or comma separated @@ -408,13 +420,12 @@ To view the commands that will be executed, without executing them, use the --pr - `--print-commands-only` — Print commands to build without executing them -###### **Verifiable:** +###### **Verifiable Options:** - `--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 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` +- `--source-uri ` — entry). Optional with `--verifiable`; the recorded `source_sha256` is computed from the generated archive, unless `--source-sha256` is explicitly set ## `stellar contract extend` @@ -1158,6 +1169,18 @@ Verify that a contract's WASM reproduces from the build metadata it records, per **Usage:** `stellar contract verify [OPTIONS]` +###### **Container Options:** + +- `-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 +- `--engine ` — Container engine to use [default: docker] + + Possible values: + - `docker`: Docker, or any Docker-compatible CLI + - `apple-container`: Apple's `container` CLI (macOS 26+, Apple silicon) + +- `--cpus ` — Limit the number of CPUs available to the container, e.g. `2`. A whole number: Apple's `container` engine does not accept fractional CPUs +- `--memory ` — Limit the memory available to the container, e.g. `2g` or `512m` + ###### **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 @@ -1169,7 +1192,6 @@ Verify that a contract's WASM reproduces from the build metadata it records, per - `--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 provided as an archive (source archives are never default-trusted) -- `-d`, `--docker-host ` — Override the default docker host used by the rebuild - `--keep` — Keep the materialized source and rebuild output instead of deleting them on exit, and print the path. Useful for debugging a byte mismatch (e.g. diffing the rebuilt WASM's metadata against the original) ###### **RPC Options:** @@ -1712,6 +1734,8 @@ Start local networks in containers - `logs` — Get logs from a running network container - `start` — Start a container running a Stellar node, RPC, API, and friendbot (faucet) - `stop` — Stop a network container started with `stellar container start` +- `use` — Set the default container engine used by `stellar container` commands +- `unset` — Unset the default container engine defined previously with `container use ` ## `stellar container logs` @@ -1728,6 +1752,11 @@ Get logs from a running network container ###### **Options:** - `-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 +- `--engine ` — Container engine to use [default: docker] + + Possible values: + - `docker`: Docker, or any Docker-compatible CLI + - `apple-container`: Apple's `container` CLI (macOS 26+, Apple silicon) ## `stellar container start` @@ -1750,6 +1779,14 @@ By default, when starting a testnet container, without any optional arguments, i ###### **Options:** - `-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 +- `--engine ` — Container engine to use [default: docker] + + Possible values: + - `docker`: Docker, or any Docker-compatible CLI + - `apple-container`: Apple's `container` CLI (macOS 26+, Apple silicon) + +- `--cpus ` — Limit the number of CPUs available to the container, e.g. `2`. A whole number: Apple's `container` engine does not accept fractional CPUs +- `--memory ` — Limit the memory available to the container, e.g. `2g` or `512m` - `--name ` — Optional argument to specify the container name - `-l`, `--limits ` — Optional argument to specify the limits for the local network only - `-p`, `--ports-mapping ` — Argument to specify the `HOST_PORT:CONTAINER_PORT` mapping @@ -1774,6 +1811,39 @@ Stop a network container started with `stellar container start` ###### **Options:** - `-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 +- `--engine ` — Container engine to use [default: docker] + + Possible values: + - `docker`: Docker, or any Docker-compatible CLI + - `apple-container`: Apple's `container` CLI (macOS 26+, Apple silicon) + +## `stellar container use` + +Set the default container engine used by `stellar container` commands + +**Usage:** `stellar container use [OPTIONS] ` + +###### **Arguments:** + +- `` — Container engine to use by default + + Possible values: + - `docker`: Docker, or any Docker-compatible CLI + - `apple-container`: Apple's `container` CLI (macOS 26+, Apple silicon) + +###### **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 + +## `stellar container unset` + +Unset the default container engine defined previously with `container use ` + +**Usage:** `stellar container unset [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 ## `stellar config` diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs index 9dab25128a..7baf74bee0 100644 --- a/cmd/soroban-cli/src/commands/contract/verify.rs +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -11,12 +11,12 @@ use walkdir::WalkDir; use crate::{ commands::{ - container, + container::shared::{self, Args as ContainerArgs, RunArgs as ContainerRunArgs}, contract::build::{ source_archive, verifiable::{self, bldimg_regex, source_sha256_regex, source_uri_regex}, }, - global, + global, HEADING_CONTAINER, }, config::{self, locator, network}, print::Print, @@ -54,10 +54,6 @@ 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, - /// Keep the materialized source and rebuild output instead of deleting them /// on exit, and print the path. Useful for debugging a byte mismatch (e.g. /// diffing the rebuilt WASM's metadata against the original). @@ -69,6 +65,12 @@ pub struct Cmd { #[command(flatten)] pub network: network::Args, + + #[command(flatten, next_help_heading = HEADING_CONTAINER)] + pub container_args: ContainerArgs, + + #[command(flatten, next_help_heading = HEADING_CONTAINER)] + pub run_args: ContainerRunArgs, } #[derive(thiserror::Error, Debug)] @@ -158,7 +160,7 @@ pub enum Error { Verifiable(#[from] verifiable::Error), #[error(transparent)] - DockerConnection(#[from] container::shared::Error), + DockerConnection(#[from] shared::Error), #[error("could not find a rebuilt WASM under {target}")] NoRebuiltWasm { target: PathBuf }, @@ -362,11 +364,12 @@ impl Cmd { global_args: &global::Args, print: &Print, ) -> Result<(), Error> { - // Rebuild in the recorded bldimg. Every docker interaction shells out to - // the `docker` CLI through this `Args` (honoring `--docker-host`). - let docker = container::shared::Args { - docker_host: self.docker_host.clone(), - }; + // Rebuild in the recorded bldimg. Every interaction shells out through + // these `container_args`, which select the engine binary (`--engine`/ + // `STELLAR_CONTAINER_ENGINE`, default docker) and honor `--docker-host` + // where the engine supports it. + let docker = self.container_args.clone(); + docker.warn_if_host_ignored(print); docker.pull_image(&meta.bldimg, print).await?; // `--locked` was only added to `contract build` in cli 25.2.0. The @@ -400,6 +403,7 @@ impl Cmd { &[container_cmd], &env, &docker, + &self.run_args, print, global_args.verbose || global_args.very_verbose, )