From e65db18e67270804ebebbf658ea7a514ded84084 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 14:41:36 -0700 Subject: [PATCH 01/14] Add --verifiable flag to stellar contract build. --- FULL_HELP_DOCS.md | 6 + cmd/crates/soroban-test/tests/it/build.rs | 82 +++ cmd/soroban-cli/src/commands/container/mod.rs | 2 +- .../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 +- 8 files changed, 756 insertions(+), 10 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 8279edacba..26b6ae0e4f 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -382,6 +382,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` @@ -392,6 +393,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 1863af976b..c4a4ed86d9 100644 --- a/cmd/crates/soroban-test/tests/it/build.rs +++ b/cmd/crates/soroban-test/tests/it/build.rs @@ -988,3 +988,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/container/mod.rs b/cmd/soroban-cli/src/commands/container/mod.rs index 08203095d3..6d025cac4e 100644 --- a/cmd/soroban-cli/src/commands/container/mod.rs +++ b/cmd/soroban-cli/src/commands/container/mod.rs @@ -1,7 +1,7 @@ use crate::commands::global; pub(crate) mod logs; -mod shared; +pub(crate) mod shared; pub(crate) mod start; pub(crate) mod stop; diff --git a/cmd/soroban-cli/src/commands/contract/build.rs b/cmd/soroban-cli/src/commands/contract/build.rs index 573d79a7d3..e40f424daf 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::curr::{Limited, Limits, ScMetaEntry, ScMetaV0, StringM, WriteXd #[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 49292baeeb..347ce995ea 100644 --- a/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs +++ b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs @@ -183,7 +183,7 @@ impl Cmd { return Err(Error::BuildOnlyNotSupported); } - let built_contracts = self.resolve_contracts(global_args)?; + let built_contracts = self.resolve_contracts(global_args).await?; // When --wasm-hash is used, no built contracts are returned. // Deploy directly with the hash. @@ -260,7 +260,7 @@ impl Cmd { Ok(()) } - fn resolve_contracts( + async fn resolve_contracts( &self, global_args: &global::Args, ) -> Result, Error> { @@ -283,7 +283,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 463adf5d21..e12e7f2552 100644 --- a/cmd/soroban-cli/src/commands/contract/upload.rs +++ b/cmd/soroban-cli/src/commands/contract/upload.rs @@ -136,7 +136,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 @@ -173,7 +173,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 { @@ -182,7 +182,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 a332132f0f20c47427bb286d8f8b4cbf5ce702e1 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 15:07:25 -0700 Subject: [PATCH 02/14] 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 d1f0258664402730471cdcaa596afb2f120f139d Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 15:18:03 -0700 Subject: [PATCH 03/14] 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 6d1b93bb81781d612d6df396133fb9dc05737ae0 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 15:36:26 -0700 Subject: [PATCH 04/14] 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 84676ce5014318bb7b62417625f27881bd144eae Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 16:19:24 -0700 Subject: [PATCH 05/14] 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 26b6ae0e4f..38185b28db 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -397,6 +397,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 c4a4ed86d9..9e3de98423 100644 --- a/cmd/crates/soroban-test/tests/it/build.rs +++ b/cmd/crates/soroban-test/tests/it/build.rs @@ -989,6 +989,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() { @@ -1002,7 +1037,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() @@ -1010,7 +1047,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(); @@ -1024,39 +1061,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 @@ -1065,7 +1181,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 e40f424daf..34e360f4b4 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 77a9f0f42a774c75eabaa74975c8c5b97d244534 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 16:20:26 -0700 Subject: [PATCH 06/14] 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 ca69b1867c606fbddb719231153d114c57482e9d Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 17:27:49 -0700 Subject: [PATCH 07/14] 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 38185b28db..c55d770cae 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -382,7 +382,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` @@ -401,6 +400,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 34e360f4b4..41764c4573 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::curr::{Limited, Limits, ScMetaEntry, ScMetaV0, StringM, WriteXd #[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 bee02df7da1516410268f5119c5463748215dd94 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 18:32:05 -0700 Subject: [PATCH 08/14] 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 f9b5b11235c2bea6855ce93f95c7abfbc925cdff Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 19:41:06 -0700 Subject: [PATCH 09/14] 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 20a31a6947650b18c70a9a97f831775aeb428182 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 22:29:10 -0700 Subject: [PATCH 10/14] 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 ecf95bfe73f58e0b348b1afcdd7925ed43f15708 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 23:49:54 -0700 Subject: [PATCH 11/14] 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 9fab0e84e02fa9a7925fdbf05530f7834e003a94 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 22 May 2026 01:02:04 -0700 Subject: [PATCH 12/14] 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 4017631a3de556261435746872509d767014db2c Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 22 May 2026 01:07:56 -0700 Subject: [PATCH 13/14] 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 bdca27b7c701d507bcf8db0ed59e18eb8ab581bd Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 22 May 2026 01:23:18 -0700 Subject: [PATCH 14/14] 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 cc44943a1e..ab4d5d3220 100644 --- a/cmd/soroban-cli/src/config/locator.rs +++ b/cmd/soroban-cli/src/config/locator.rs @@ -574,52 +574,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); - } } }