From 60e7f3526419babd5a3ba7eb71a8a00ea62b83d0 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 12 Aug 2026 11:06:29 -0300 Subject: [PATCH 1/4] Add container support to contract build. --- Cargo.lock | 1 + FULL_HELP_DOCS.md | 20 + cmd/crates/soroban-test/tests/it/build.rs | 59 + cmd/soroban-cli/Cargo.toml | 5 + .../src/commands/container/shared.rs | 44 +- .../src/commands/contract/build.rs | 105 +- .../src/commands/contract/build/container.rs | 1083 +++++++++++++++++ .../src/commands/contract/deploy/wasm.rs | 6 +- cmd/soroban-cli/src/commands/contract/mod.rs | 2 +- .../src/commands/contract/upload.rs | 6 +- cmd/soroban-cli/src/commands/mod.rs | 1 + 11 files changed, 1318 insertions(+), 14 deletions(-) create mode 100644 cmd/soroban-cli/src/commands/contract/build/container.rs diff --git a/Cargo.lock b/Cargo.lock index a63546cca6..1f3dc66cfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5426,6 +5426,7 @@ dependencies = [ "rpassword", "rust-embed", "rustc_version", + "rustix 1.0.8", "semver", "sep5", "serde", diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index cbe15c0666..dfea30eee9 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -356,6 +356,26 @@ To view the commands that will be executed, without executing them, use the --pr **Usage:** `stellar contract build [OPTIONS]` +###### **Container Options:** + +- `--image ` — Build inside this container image (e.g. `docker.io/stellar/stellar-cli:latest`). When set, the build runs in the container against the bind-mounted working tree instead of locally. Any tag or digest ref is accepted. + + On Linux the container runs as your uid:gid so built wasm isn't root-owned; this assumes the image keeps CARGO_HOME/RUSTUP_HOME writable by non-root users, as the official image does. + +- `--no-image-pull` — Don't pull `--image` before building; use the copy already present locally. + + Lets you build against a locally-built (never pushed) image or a digest-pinned image already on disk, and to work offline — e.g. air-gapped verification against a pinned digest. Fails if the image isn't present. + +- `-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 diff --git a/cmd/crates/soroban-test/tests/it/build.rs b/cmd/crates/soroban-test/tests/it/build.rs index b5f7631ca6..7aa9058f3a 100644 --- a/cmd/crates/soroban-test/tests/it/build.rs +++ b/cmd/crates/soroban-test/tests/it/build.rs @@ -32,6 +32,65 @@ cargo rustc {} --crate-type=cdylib --target=wasm32v1-none --release", .stdout(predicate::eq(with_flags(expected.as_str()))); } +#[test] +fn build_with_image_print_commands_only_multi_package() { + // With `--image`, `--print-commands-only` prints the container run command + // instead of the local cargo commands, without touching the engine. The + // workspace has several default-member cdylibs, so they chain through + // `/bin/sh -c`. + let sandbox = TestEnv::default(); + let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/workspace/"); + sandbox + .new_assert_cmd("contract") + .current_dir(fixture_path) + .arg("build") + .arg("--image") + .arg("docker.io/stellar/stellar-cli:latest") + .arg("--print-commands-only") + .assert() + .success() + .stdout( + predicate::str::starts_with("docker run --rm") + .and(predicate::str::contains("-w /source")) + .and(predicate::str::contains("--entrypoint /bin/sh")) + .and(predicate::str::contains( + "docker.io/stellar/stellar-cli:latest", + )) + .and(predicate::str::contains( + "stellar contract build --package=add", + )) + .and(predicate::str::contains("&&")) + .and(predicate::str::contains("cargo rustc").not()), + ); +} + +#[test] +fn build_with_image_print_commands_only_single_package() { + // A single package runs the image's default entrypoint directly — no shell + // wrapper. + let sandbox = TestEnv::default(); + let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/workspace/"); + sandbox + .new_assert_cmd("contract") + .current_dir(fixture_path) + .arg("build") + .arg("--image") + .arg("docker.io/stellar/stellar-cli:latest") + .arg("--package=add") + .arg("--print-commands-only") + .assert() + .success() + .stdout( + predicate::str::contains( + "'docker.io/stellar/stellar-cli:latest' contract build --package=add --optimize", + ) + .and(predicate::str::contains("--entrypoint").not()) + .and(predicate::str::contains("cargo rustc").not()), + ); +} + #[test] fn build_package_by_name() { let sandbox = TestEnv::default(); diff --git a/cmd/soroban-cli/Cargo.toml b/cmd/soroban-cli/Cargo.toml index 76d6fc0323..1e1d262259 100644 --- a/cmd/soroban-cli/Cargo.toml +++ b/cmd/soroban-cli/Cargo.toml @@ -129,6 +129,11 @@ whoami = "1.5.2" serde_with = "3.11.0" rustc_version = "0.4.1" +# Used to read the current uid/gid so container builds don't leave root-owned +# artifacts on Linux bind mounts. +[target.'cfg(target_os = "linux")'.dependencies] +rustix = { version = "1", features = ["process"] } + [build-dependencies] crate-git-revision = "0.0.9" serde.workspace = true diff --git a/cmd/soroban-cli/src/commands/container/shared.rs b/cmd/soroban-cli/src/commands/container/shared.rs index b621aea9ca..3bbd073918 100644 --- a/cmd/soroban-cli/src/commands/container/shared.rs +++ b/cmd/soroban-cli/src/commands/container/shared.rs @@ -108,7 +108,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")] @@ -187,11 +187,27 @@ impl Args { self.engine().is_container_not_found(stderr) } + /// The engine invocation prefix for copy-pasteable reproduce/print lines, + /// mirroring [`base_command`](Self::base_command): the binary name plus + /// `-H ` when the docker engine honors a configured + /// `--docker-host`/`DOCKER_HOST`. Shell-escaped so it round-trips. + pub(crate) fn command_prefix(&self) -> String { + let engine = self.engine(); + let mut prefix = engine.program().to_string(); + if engine.supports_docker_host() { + if let Some(host) = &self.docker_host { + prefix.push_str(" -H "); + prefix.push_str(&shell_escape::escape(host.into())); + } + } + prefix + } + /// Builds the base command for the selected engine. For docker, a /// `--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() { @@ -227,6 +243,15 @@ impl Args { cmd } + /// Immediately kill (SIGKILL) a running container by name. Used to tear down + /// a build container when the CLI is interrupted, where `stop`'s grace + /// period would let the build keep running while we block waiting. + 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() { @@ -440,6 +465,21 @@ mod test { ); } + #[test] + fn command_prefix_reflects_docker_host_and_apple_ignores_it() { + assert_eq!(args(None, None).command_prefix(), "docker"); + // The host is shell-escaped so the reproduce line round-trips. + assert_eq!( + args(Some("ssh://host"), None).command_prefix(), + "docker -H 'ssh://host'" + ); + // Apple ignores the host and uses its own binary name. + assert_eq!( + args(Some("ssh://host"), Some(Engine::AppleContainer)).command_prefix(), + "container" + ); + } + #[test] fn host_ignored_warning_only_for_non_docker_engines() { assert!(args(Some("ssh://host"), Some(Engine::AppleContainer)) diff --git a/cmd/soroban-cli/src/commands/contract/build.rs b/cmd/soroban-cli/src/commands/contract/build.rs index bc670cb026..d5aa5d59b2 100644 --- a/cmd/soroban-cli/src/commands/contract/build.rs +++ b/cmd/soroban-cli/src/commands/contract/build.rs @@ -21,11 +21,16 @@ use stellar_xdr::{Limited, Limits, ScMetaEntry, ScMetaV0, StringM, WriteXdr}; use crate::commands::contract::optimize; use crate::utils::XDR_DEPTH_LIMIT; use crate::{ - commands::{global, version}, + commands::{ + container::shared::{Args as ContainerArgs, RunArgs as ContainerRunArgs}, + global, version, HEADING_CONTAINER, + }, print::Print, wasm, }; +pub mod container; + /// A built WASM artifact with its package name and file path. #[derive(Debug, Clone)] pub struct BuiltContract { @@ -97,8 +102,39 @@ pub struct Cmd { #[arg(long, conflicts_with = "out_dir", help_heading = "Other")] pub print_commands_only: bool, + /// Build inside this container image (e.g. + /// `docker.io/stellar/stellar-cli:latest`). When set, the build runs in the + /// container against the bind-mounted working tree instead of locally. Any + /// tag or digest ref is accepted. + /// + /// On Linux the container runs as your uid:gid so built wasm isn't + /// root-owned; this assumes the image keeps CARGO_HOME/RUSTUP_HOME writable + /// by non-root users, as the official image does. + #[arg(long, help_heading = HEADING_CONTAINER)] + pub image: Option, + + /// Don't pull `--image` before building; use the copy already present locally. + /// + /// Lets you build against a locally-built (never pushed) image or a + /// digest-pinned image already on disk, and to work offline — e.g. air-gapped + /// verification against a pinned digest. Fails if the image isn't present. + #[arg(long, requires = "image", help_heading = HEADING_CONTAINER)] + pub no_image_pull: bool, + #[command(flatten)] pub build_args: BuildArgs, + + // Declared after `build_args` so their `next_help_heading` groups them under + // the Container heading without leaking it onto the ungrouped flags above. + /// Container connection options (`--engine`, `--docker-host`) used when + /// `--image` is set. `--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 + /// `--image` 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. @@ -205,10 +241,13 @@ pub enum Error { #[error("wasm parsing error: {0}")] WasmParsing(String), + + #[error(transparent)] + Container(#[from] container::Error), } -const WASM_TARGET: &str = "wasm32v1-none"; -const WASM_TARGET_OLD: &str = "wasm32-unknown-unknown"; +pub(crate) const WASM_TARGET: &str = "wasm32v1-none"; +pub(crate) const WASM_TARGET_OLD: &str = "wasm32-unknown-unknown"; const META_CUSTOM_SECTION_NAME: &str = "contractmetav0"; impl Default for Cmd { @@ -223,7 +262,11 @@ impl Default for Cmd { out_dir: None, locked: false, print_commands_only: false, + image: None, + no_image_pull: false, build_args: BuildArgs::default(), + container_args: ContainerArgs::default(), + run_args: ContainerRunArgs::default(), } } } @@ -231,8 +274,14 @@ 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); + + // When an image is given, build inside that container instead of locally. + if self.image.is_some() { + return container::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)?; @@ -716,7 +765,7 @@ fn get_rustflags() -> Option> { None } -fn get_wasm_target() -> Result { +pub(crate) fn get_wasm_target() -> Result { let Ok(current_version) = version() else { return Ok(WASM_TARGET.into()); }; @@ -841,6 +890,52 @@ pub fn filter_and_dedup_spec( mod tests { use super::*; + #[test] + fn image_flag_parses_with_tag_and_container_options() { + let cmd = Cmd::try_parse_from([ + "build", + "--image", + "docker.io/stellar/stellar-cli:latest", + "--meta", + "field=value", + "--engine", + "docker", + "--cpus", + "2", + ]) + .expect("--image with a tag ref and container options must parse"); + assert_eq!( + cmd.image.as_deref(), + Some("docker.io/stellar/stellar-cli:latest") + ); + assert_eq!( + cmd.build_args.meta, + vec![("field".to_string(), "value".to_string())] + ); + assert_eq!(cmd.run_args.cpus, Some(2)); + } + + #[test] + fn image_defaults_to_none() { + let cmd = Cmd::try_parse_from(["build"]).unwrap(); + assert!(cmd.image.is_none()); + } + + #[test] + fn no_image_pull_requires_image() { + let cmd = Cmd::try_parse_from([ + "build", + "--image", + "docker.io/stellar/stellar-cli:latest", + "--no-image-pull", + ]) + .expect("--no-image-pull with --image must parse"); + assert!(cmd.no_image_pull); + + // Without --image the flag is rejected rather than silently ignored. + assert!(Cmd::try_parse_from(["build", "--no-image-pull"]).is_err()); + } + #[test] fn serialize_command_shell_escapes_args_with_metacharacters() { let raw_arg = "--manifest-path=/path/to/contract;touch PWNED;#/Cargo.toml"; diff --git a/cmd/soroban-cli/src/commands/contract/build/container.rs b/cmd/soroban-cli/src/commands/contract/build/container.rs new file mode 100644 index 0000000000..c1a0ecef43 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/build/container.rs @@ -0,0 +1,1083 @@ +//! Build a contract inside a container image. +//! +//! Triggered by `stellar contract build --image `: instead of compiling +//! locally, the working tree is bind-mounted into the given container image at +//! `/source` and `stellar contract build` is run there. The resulting wasm is +//! written into the mounted `target/` directory and therefore lands on the host +//! directly. Any image ref is accepted — a tag (`:latest`) or a digest. +//! +//! This is deliberately standalone: no source archive, no clean-git-tree +//! requirement, no reproducibility metadata. It reuses the container engine +//! abstraction in [`crate::commands::container::shared`], so `--engine`, +//! `--docker-host`, and the default engine set by `stellar container use` all +//! apply. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +use cargo_metadata::MetadataCommand; +use semver::Version; + +use crate::commands::{container::shared, global}; +use crate::print::Print; + +use super::{get_wasm_target, BuiltContract, Cmd, WASM_TARGET, WASM_TARGET_OLD}; + +/// First CLI release whose `contract build` accepts `--locked` (added in cli +/// v25.2.0). Older images reject it, so it's dropped (with a warning) on anything +/// older, matching the version detected from the image's own `version` output. +const LOCKED_MIN: &str = "25.2.0"; + +/// First CLI release whose `contract build` has the `--optimize` flag at all. +/// Older images reject it, so — since optimization is on by default — this is the +/// effective minimum supported image. We probe the image's `version` and skip the +/// flag (with a warning) on anything older. +const OPTIMIZE_FLAG_MIN: &str = "23.2.0"; + +/// First CLI release whose `contract build` accepts `--optimize=false` as an +/// explicit value. Images between [`OPTIMIZE_FLAG_MIN`] and this default to *not* +/// optimizing, so for them we forward nothing to get an unoptimized build. +const OPTIMIZE_NEW_SYNTAX_MIN: &str = "26.1.0"; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Engine(#[from] shared::Error), + + #[error("could not pull image {image}")] + PullImageFailed { image: String }, + + #[error( + "could not determine the image's default Rust toolchain via `rustup default`; \ + the image must provide rustup so the build toolchain can be pinned" + )] + ToolchainProbeFailed, + + #[error("cargo metadata failed: {0}")] + Metadata(#[from] cargo_metadata::Error), + + #[error("container build exited with status {status}. To reproduce manually:\n {command}")] + ContainerExit { status: i64, command: String }, + + #[error("build interrupted; stopped the build container")] + Interrupted, +} + +pub async fn run( + cmd: &Cmd, + _global_args: &global::Args, + print: &Print, +) -> Result, super::Error> { + let image = cmd + .image + .as_deref() + .expect("container::run is only called when --image is set"); + + let docker = cmd.container_args.clone(); + docker.warn_if_host_ignored(print); + + // Bind-mount the workspace root so every crate is available and relative + // manifest paths resolve inside the container. + let workspace_root = resolve_workspace_root(cmd)?; + + // With `--print-commands-only` nothing runs, so don't pull, probe, or build; + // just render the run command against a current image below. + let print_only = cmd.print_commands_only; + + // Pull unless printing (nothing runs) or the user opted out with + // `--no-image-pull` to use a locally present image (offline / air-gapped + // verification, or a locally-built image that was never pushed). + if !print_only && !cmd.no_image_pull { + pull_image(&docker, image, print).await?; + } + + // Gather everything we need to know about the image in one throwaway + // container (binary name, CLI version, default rustup toolchain), so slow or + // remote engines pay a single round-trip instead of one per fact. When only + // printing the command we can't probe (that would run a container), so a + // current image is assumed and the toolchain pin is omitted. + let probe = if print_only { + None + } else { + Some(probe_image(image, &docker).await?) + }; + // The CLI version drives flag gating; a probed image that didn't report a + // parseable version is treated as current (with a warning). + let cli_version = match &probe { + Some(p) if p.version.is_none() => { + print.warnln("Could not probe container cli version; assuming a current image"); + None + } + Some(p) => p.version.clone(), + None => None, + }; + let at_least = |min: &str| { + cli_version + .as_ref() + .is_none_or(|v| *v >= Version::parse(min).unwrap()) + }; + // `--locked` was added in v25.2.0, the `--optimize` flag in v23.2.0, and its + // explicit `--optimize=false` value in v26.1.0. + let supports_locked = at_least(LOCKED_MIN); + let supports_optimize_flag = at_least(OPTIMIZE_FLAG_MIN); + let supports_optimize_false = at_least(OPTIMIZE_NEW_SYNTAX_MIN); + if cmd.locked && !supports_locked { + print.warnln( + "The build image's `contract build` does not support --locked; \ + building without it.", + ); + } + if cmd.build_args.optimize && !supports_optimize_flag { + print.warnln(format!( + "The build image's `contract build` does not support --optimize \ + (added in cli v{OPTIMIZE_FLAG_MIN}); building without optimization.", + )); + } + + // Build once per package so workspaces with several cdylibs all get built; + // an explicit `--package` wins, otherwise the default-member cdylibs are + // inferred exactly like a local build. + let packages = resolve_packages(cmd)?; + if cmd.package.is_none() && !packages.is_empty() { + print.infoln(format!("Building packages: {}", packages.join(", "))); + } + 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| { + forwarded_build_args( + cmd, + &workspace_root, + *target, + supports_locked, + supports_optimize_flag, + supports_optimize_false, + ) + }) + .collect(); + + // Reset the target dir to a known location under the mount, independent of + // any mounted `.cargo/config` `build.target-dir` or image env, so we always + // know where to collect artifacts. + let mut env: Vec = vec!["CARGO_TARGET_DIR=/source/target".to_string()]; + + // Pin RUSTUP_TOOLCHAIN to the image's own default toolchain so a + // `rust-toolchain.toml` in the mounted source can't redirect the build to a + // different toolchain — which rustup would then try to install (needing + // network access and possibly lacking the wasm target). An empty + // RUSTUP_TOOLCHAIN would *not* achieve this: rustup treats it as unset and + // still honors rust-toolchain.toml, so the probe reports the concrete + // toolchain name (guaranteed non-empty; `probe_image` hard-fails otherwise). + // Skipped when only printing the command, where nothing is probed. + if let Some(p) = &probe { + print.infoln(format!("Using Rust toolchain {}", p.toolchain)); + env.push(format!("RUSTUP_TOOLCHAIN={}", p.toolchain)); + } + + // Chaining several builds through `/bin/sh` invokes the CLI by name, which + // differs across images (`soroban` before v21.0.0, `stellar` since). The + // single-build path uses the image's entrypoint and doesn't care. Default to + // `stellar` when not probed (print-only). + let bin = probe + .as_ref() + .map_or_else(|| "stellar".to_string(), |p| p.bin.clone()); + + run_in_container( + image, + &workspace_root, + &container_cmds, + &env, + &docker, + &cmd.run_args, + &bin, + print, + print_only, + ) + .await?; + + // Nothing was built when only printing the command. + if print_only { + return Ok(Vec::new()); + } + + collect_built_contracts(cmd, &workspace_root) +} + +fn resolve_workspace_root(cmd: &Cmd) -> Result { + Ok(metadata(cmd)?.workspace_root.into_std_path_buf()) +} + +fn metadata(cmd: &Cmd) -> Result { + let mut mc = MetadataCommand::new(); + mc.no_deps(); + if let Some(p) = &cmd.manifest_path { + mc.manifest_path(p); + } + mc.exec() +} + +/// Resolve the packages to build. An explicit `--package` wins; otherwise the +/// default-member crates that build a cdylib, mirroring the local build's +/// package selection. May be empty (no cdylib default members), in which case +/// the caller falls back to a single no-`--package` build. +fn resolve_packages(cmd: &Cmd) -> Result, Error> { + if let Some(pkg) = &cmd.package { + return Ok(vec![pkg.clone()]); + } + let md = metadata(cmd)?; + 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) +} + +/// The `contract build …` argv forwarded to the container, mirroring the local +/// build's flags. `--manifest-path` is relativized against the workspace root so +/// it's valid inside `/source`. `--out-dir` is deliberately omitted — artifacts +/// are collected on the host from the mounted `target/`. +/// +/// `supports_locked`: whether the container's `contract build` accepts `--locked` +/// (added in cli 25.2.0). When false, the user's `--locked` is dropped rather +/// than forwarded to an image that would reject it. +/// +/// `supports_optimize_flag`: whether the container's cli has the `--optimize` +/// flag at all (added in cli 23.2.0). When false, nothing about optimize is +/// forwarded — the flag would be rejected as unknown. +/// +/// `supports_optimize_false`: whether the container's cli accepts +/// `--optimize=false` (added in cli 26.1.0). When false and the user disabled +/// optimization, nothing is forwarded — the older cli defaults to not +/// optimizing, and passing `--optimize=false` there would fail. +fn forwarded_build_args( + cmd: &Cmd, + workspace_root: &Path, + package: Option<&str>, + supports_locked: bool, + supports_optimize_flag: bool, + supports_optimize_false: bool, +) -> Vec { + let mut args = vec!["contract".to_string(), "build".to_string()]; + + if cmd.locked && supports_locked { + args.push("--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); + args.push(format!("--manifest-path={}", rel.display())); + } + if cmd.profile != "release" { + args.push(format!("--profile={}", cmd.profile)); + } + if let Some(features) = &cmd.features { + args.push(format!("--features={features}")); + } + if cmd.all_features { + args.push("--all-features".to_string()); + } + if cmd.no_default_features { + args.push("--no-default-features".to_string()); + } + if let Some(pkg) = package { + args.push(format!("--package={pkg}")); + } + for (k, v) in &cmd.build_args.meta { + args.push(format!("--meta={k}={v}")); + } + // Optimization is forwarded per the image's cli version. To enable it, bare + // `--optimize` on images >= v23.2.0 (older images lack the flag entirely, so + // forward nothing). To disable it, `--optimize=false` on images >= v26.1.0; + // older ones default to not optimizing, so forwarding nothing matches. + if cmd.build_args.optimize { + if supports_optimize_flag { + args.push("--optimize".to_string()); + } + } else if supports_optimize_false { + args.push("--optimize=false".to_string()); + } + + args +} + +async fn pull_image(docker: &shared::Args, image: &str, print: &Print) -> Result<(), Error> { + print.infoln(format!("Pulling image {image}")); + let (stdout, stderr) = if print.quiet { + (Stdio::null(), Stdio::null()) + } else { + (Stdio::inherit(), Stdio::inherit()) + }; + let status = docker + .pull_command(image) + .stdout(stdout) + .stderr(stderr) + .status() + .await + .map_err(|e| docker.io_error(e))?; + if !status.success() { + return Err(Error::PullImageFailed { + image: image.to_string(), + }); + } + Ok(()) +} + +/// Run `cmd` in a throwaway `docker run --rm` container (optionally overriding +/// the entrypoint) and return its captured stdout. stderr and the exit status +/// are ignored — every probe treats a missing subcommand or unexpected output as +/// "unsupported". +async fn run_probe( + image: &str, + docker: &shared::Args, + entrypoint: Option<&str>, + cmd: Vec, +) -> Result { + let mut command = docker.base_command(); + command.args(["run", "--rm"]); + if let Some(entrypoint) = entrypoint { + command.args(["--entrypoint", entrypoint]); + } + command.arg(image); + command.args(&cmd); + + let output = command.output().await.map_err(|e| docker.io_error(e))?; + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// Facts probed from the image before building, gathered in one throwaway +/// container to avoid a round-trip per fact. +struct ImageProbe { + /// CLI binary on the image's PATH — `stellar` (v21.0.0+) or `soroban` + /// (older). Used when invoking the CLI by name in the chained multi-build + /// command; the single-build path uses the image's entrypoint instead. + bin: String, + /// Parsed CLI version, or `None` when the image reported no parseable version + /// (treated as a current image by the caller). + version: Option, + /// The image's default rustup toolchain (e.g. + /// `1.97.1-aarch64-unknown-linux-gnu`), pinned into `RUSTUP_TOOLCHAIN`. + /// Guaranteed non-empty — the probe hard-fails when it can't be determined. + toolchain: String, +} + +/// Probe the image once for everything the build needs: the CLI binary name, its +/// version, and the default rustup toolchain. Runs a single `/bin/sh` script +/// (the same `/bin/sh` and `rustup` the multi-build path and toolchain pin +/// already require) that detects the binary, then reports each fact on its own +/// tagged line so the combined stdout can be split apart. Hard-fails when no +/// default toolchain can be determined, rather than building unpinned. +async fn probe_image(image: &str, docker: &shared::Args) -> Result { + // Detect the binary first, then run `$bin version` (version on its first + // line) and `rustup default` (the toolchain name). Tag each line so we can + // pick the values back out regardless of any extra output. + let script = "\ + bin=\"$(command -v stellar >/dev/null 2>&1 && echo stellar || echo soroban)\"\n\ + printf 'BIN:%s\\n' \"$bin\"\n\ + printf 'VERSION:%s\\n' \"$(\"$bin\" version 2>/dev/null | head -n1)\"\n\ + printf 'TOOLCHAIN:%s\\n' \"$(rustup default 2>/dev/null)\"\n"; + let stdout = run_probe( + image, + docker, + Some("/bin/sh"), + vec!["-c".to_string(), script.to_string()], + ) + .await?; + + let bin = match probe_value(&stdout, "BIN:") { + "" => "stellar".to_string(), + b => b.to_string(), + }; + let version = parse_cli_version(probe_value(&stdout, "VERSION:")); + let toolchain = parse_default_toolchain(probe_value(&stdout, "TOOLCHAIN:")) + .ok_or(Error::ToolchainProbeFailed)?; + + Ok(ImageProbe { + bin, + version, + toolchain, + }) +} + +/// Pull the value of a `TAG:value` line out of the combined probe output. Returns +/// an empty string when the tag is absent (the fact couldn't be gathered). +fn probe_value<'a>(stdout: &'a str, tag: &str) -> &'a str { + stdout + .lines() + .find_map(|l| l.strip_prefix(tag)) + .map(str::trim) + .unwrap_or_default() +} + +/// Extract the cli version from `version` output. The first line looks like +/// `stellar 27.1.0 ()` or `soroban-cli 0.1.2 ()`; later lines carry +/// unrelated numbers (`stellar-xdr 22.1.0`, `soroban-env-interface-version: 23`), +/// so only the first line is considered, taking its first valid-semver token. +fn parse_cli_version(stdout: &str) -> Option { + stdout + .lines() + .next()? + .split_whitespace() + .find_map(|tok| Version::parse(tok).ok()) +} + +/// Extract the toolchain name from `rustup default` output, which looks like +/// `1.97.1-aarch64-unknown-linux-gnu (default)`. Returns `None` when the output +/// is empty (e.g. the image has no default toolchain or lacks `rustup`). +fn parse_default_toolchain(stdout: &str) -> Option { + stdout.split_whitespace().next().map(str::to_string) +} + +#[allow(clippy::too_many_arguments)] +async fn run_in_container( + image: &str, + workspace_root: &Path, + container_cmds: &[Vec], + env: &[String], + docker: &shared::Args, + run_args: &shared::RunArgs, + bin: &str, + print: &Print, + print_only: bool, +) -> Result<(), Error> { + let bind = format!("{}:/source", workspace_root.display()); + // The engine prefix for the reproduce line mirrors `base_command`, including + // `-H ` so a copy-paste hits the same daemon the CLI used. + let prefix = docker.command_prefix(); + + // `-e KEY=VALUE` flags, mirrored into the reproduce line 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())); + } + + // On Linux, run as the host uid:gid so wasm the container writes into the + // bind-mounted `target/` is owned by the invoking user instead of root. + // Docker Desktop (macOS) and Apple's `container` map ownership to the host + // user already, so this is Linux-only. + // + // This assumes the image keeps CARGO_HOME/RUSTUP_HOME writable by non-root + // users, which the official rust-based image does. An arbitrary `--image` + // with root-owned toolchain dirs may fail the build under this uid — a known + // limitation of running unofficial images. + let user_flags: Vec = current_user_flags(); + + // Run flags for the copy-pasteable reproduce line, matching where they're + // applied to the spawned command below. + let mut run_flags = String::new(); + for f in run_args.flags().iter().chain(user_flags.iter()) { + run_flags.push(' '); + run_flags.push_str(&shell_escape::escape(f.as_str().into())); + } + + let (entrypoint, post_image, reproduce) = compose_invocation( + &prefix, + &run_flags, + &bind, + &env_flags, + image, + bin, + container_cmds, + ); + + // `--print-commands-only`: emit the run command to stdout (so it's + // pipeable) and stop, without touching the engine. + if print_only { + println!("{reproduce}"); + return Ok(()); + } + + print.infoln(format!("Building in {image} (mount {bind})")); + print.infoln(format!("Running: {reproduce}")); + + // Name the container so it can be stopped if the CLI is interrupted: the + // daemon owns the container, so the client exiting doesn't stop it. Unique + // per invocation so concurrent builds don't collide, and kept out of the + // reproduce line where a fixed name would clash on re-run. + let container_name = format!( + "stellar-contract-build-{}-{:08x}", + std::process::id(), + rand::random::() + ); + + let mut command = docker.base_command(); + command.args(["run", "--rm", "--name", &container_name]); + run_args.apply(&mut command); + command.args(&user_flags); + command.args(["-v", &bind, "-w", "/source"]); + for e in env { + command.args(["-e", e]); + } + if let Some(entrypoint) = entrypoint { + command.args(["--entrypoint", entrypoint]); + } + command.arg(image); + command.args(&post_image); + + // Stream the build output straight to the terminal (matching a local build); + // `quiet` discards it. + let (stdout, stderr) = if print.quiet { + (Stdio::null(), Stdio::null()) + } else { + (Stdio::inherit(), Stdio::inherit()) + }; + command.stdout(stdout).stderr(stderr); + + let mut child = command.spawn().map_err(|e| docker.io_error(e))?; + + // Race the build against any catchable termination signal. On a signal, kill + // 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"); + 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(), + command: reproduce, + }); + } + + Ok(()) +} + +/// `--user :` for the current process on Linux, so container-written +/// artifacts on bind mounts are owned by the invoking user rather than root. +/// Empty on every other platform, where the engine's VM maps ownership to the +/// host user already. +#[cfg(target_os = "linux")] +fn current_user_flags() -> Vec { + let uid = rustix::process::getuid().as_raw(); + let gid = rustix::process::getgid().as_raw(); + vec!["--user".to_string(), format!("{uid}:{gid}")] +} + +#[cfg(not(target_os = "linux"))] +fn current_user_flags() -> Vec { + Vec::new() +} + +/// Build the run invocation: the optional entrypoint override, the args after +/// the image, and a copy-pasteable reproduce line (also what +/// `--print-commands-only` emits). One package runs the image's default +/// entrypoint directly; several override the entrypoint to `/bin/sh` and chain +/// the builds (invoking the CLI by `bin` name) so they share one container (and +/// its crates download / compiled deps / `target/`). +fn compose_invocation( + prefix: &str, + run_flags: &str, + bind: &str, + env_flags: &str, + image: &str, + bin: &str, + container_cmds: &[Vec], +) -> (Option<&'static str>, Vec, String) { + // The reproduce line is documented as copy-pasteable, so escape the bind + // mount (which embeds the workspace path) and image ref like every other + // token; a path with a space or shell metacharacter must still round-trip. + let bind = shell_escape::escape(bind.into()); + let image = shell_escape::escape(image.into()); + if container_cmds.len() > 1 { + let chain = compose_shell_command(bin, container_cmds); + let reproduce = format!( + "{prefix} run --rm{run_flags} -v {bind} -w /source{env_flags} --entrypoint /bin/sh {image} -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!( + "{prefix} run --rm{run_flags} -v {bind} -w /source{env_flags} {image} {}", + escape_args(&cmd) + ); + (None, cmd, reproduce) + } +} + +/// Render the per-package ` contract build …` commands into a single +/// `sh -c` script (` … && …`), shell-escaping every token so values +/// with spaces survive. `bin` is the container's CLI binary (`soroban` or +/// `stellar`). +fn compose_shell_command(bin: &str, cmds: &[Vec]) -> String { + cmds.iter() + .map(|cmd| { + std::iter::once(bin) + .chain(cmd.iter().map(String::as_str)) + .map(|tok| shell_escape::escape(tok.into()).into_owned()) + .collect::>() + .join(" ") + }) + .collect::>() + .join(" && ") +} + +/// Shell-escape each token of a single-package command for the reproduce line so +/// a copy-paste round-trips back to the same argv. +fn escape_args(cmd: &[String]) -> String { + cmd.iter() + .map(|tok| shell_escape::escape(tok.into()).into_owned()) + .collect::>() + .join(" ") +} + +/// Resolve once the process receives any catchable signal that would otherwise +/// terminate it, so the caller can stop the build container before exiting. +#[cfg(unix)] +async fn wait_for_termination_signal() { + use tokio::signal::unix::{signal, SignalKind}; + + 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!` 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; +} + +/// Collect the built wasm from the mounted `target/`. Because the working tree +/// was bind-mounted, the container writes artifacts straight to the host under +/// `/target///`. The container's rust toolchain +/// decides the target triple, so both known triples are probed. Copies to +/// `--out-dir` when set. +fn collect_built_contracts( + cmd: &Cmd, + workspace_root: &Path, +) -> Result, super::Error> { + let md = metadata(cmd).map_err(Error::from)?; + let target_root = workspace_root.join("target"); + + 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 file = format!("{}.wasm", p.name.replace('-', "_")); + // The container may build for either wasm target depending on its rust + // version; fall back to the current host default for the reported path. + let src = [WASM_TARGET, WASM_TARGET_OLD] + .iter() + .map(|triple| target_root.join(triple).join(&cmd.profile).join(&file)) + .find(|path| path.exists()) + .unwrap_or_else(|| { + let triple = get_wasm_target().unwrap_or_else(|_| WASM_TARGET.to_string()); + target_root.join(triple).join(&cmd.profile).join(&file) + }); + + let path = if let Some(out_dir) = &cmd.out_dir { + std::fs::create_dir_all(out_dir).map_err(super::Error::CreatingOutDir)?; + let dest = out_dir.join(&file); + if src.exists() { + std::fs::copy(&src, &dest).map_err(super::Error::CopyingWasmFile)?; + } + dest + } else { + src + }; + + out.push(BuiltContract { + name: p.name.clone(), + path, + }); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::contract::build::BuildArgs; + + fn ws() -> &'static Path { + Path::new("/tmp/ws") + } + + #[test] + fn forwarded_build_args_defaults() { + let cmd = Cmd::default(); + let args = forwarded_build_args(&cmd, ws(), None, true, true, true); + assert_eq!(args[..2], ["contract".to_string(), "build".to_string()]); + // Default optimize=true → bare `--optimize`; no `--locked` unless asked. + assert!(args.contains(&"--optimize".to_string())); + assert!(!args.iter().any(|a| a == "--locked")); + assert!(!args.iter().any(|a| a.starts_with("--package"))); + } + + #[test] + fn forwarded_build_args_locked_and_package() { + let cmd = Cmd { + locked: true, + ..Cmd::default() + }; + let args = forwarded_build_args(&cmd, ws(), Some("contract-a"), true, true, true); + assert!(args.contains(&"--locked".to_string())); + assert!(args.contains(&"--package=contract-a".to_string())); + } + + #[test] + fn forwarded_build_args_drops_locked_when_unsupported() { + // User asked for --locked but the image's cli doesn't accept it. + let cmd = Cmd { + locked: true, + ..Cmd::default() + }; + let args = forwarded_build_args(&cmd, ws(), None, false, true, true); + assert!(!args.iter().any(|a| a == "--locked")); + } + + #[test] + fn forwarded_build_args_omits_optimize_when_flag_unsupported() { + // Image older than v23.2.0 has no `--optimize` flag; forward nothing even + // though optimize defaults to true. + let cmd = Cmd::default(); + assert!(cmd.build_args.optimize); + let args = forwarded_build_args(&cmd, ws(), None, true, false, false); + assert!(!args.iter().any(|a| a.starts_with("--optimize"))); + } + + #[test] + fn forwarded_build_args_features_meta_and_profile() { + let cmd = Cmd { + profile: "dev".to_string(), + features: Some("a,b".to_string()), + all_features: true, + no_default_features: true, + build_args: BuildArgs { + meta: vec![ + ("home_domain".to_string(), "example.com".to_string()), + ("author".to_string(), "alice".to_string()), + ], + optimize: false, + }, + ..Cmd::default() + }; + let args = forwarded_build_args(&cmd, ws(), None, true, true, true); + assert!(args.contains(&"--profile=dev".to_string())); + assert!(args.contains(&"--features=a,b".to_string())); + assert!(args.contains(&"--all-features".to_string())); + assert!(args.contains(&"--no-default-features".to_string())); + assert!(args.contains(&"--meta=home_domain=example.com".to_string())); + assert!(args.contains(&"--meta=author=alice".to_string())); + assert!(args.contains(&"--optimize=false".to_string())); + } + + #[test] + fn forwarded_build_args_optimize_false_old_image_forwards_nothing() { + // Old image defaults to not optimizing and rejects `--optimize=false`, + // so nothing about optimize is forwarded. + let cmd = Cmd { + build_args: BuildArgs { + optimize: false, + ..BuildArgs::default() + }, + ..Cmd::default() + }; + let args = forwarded_build_args(&cmd, ws(), None, true, true, false); + assert!(!args.iter().any(|a| a.starts_with("--optimize"))); + } + + #[test] + fn forwarded_build_args_relativizes_manifest_path() { + let cmd = Cmd { + manifest_path: Some(PathBuf::from("/tmp/ws/contracts/add/Cargo.toml")), + ..Cmd::default() + }; + let args = forwarded_build_args(&cmd, ws(), None, true, true, true); + assert!(args.contains(&"--manifest-path=contracts/add/Cargo.toml".to_string())); + } + + #[test] + fn compose_shell_command_chains_and_escapes() { + let a = vec![ + "contract".to_string(), + "build".to_string(), + "--package=another".to_string(), + "--meta=note=added on build".to_string(), + ]; + let b = vec![ + "contract".to_string(), + "build".to_string(), + "--package=hello-world".to_string(), + ]; + let s = compose_shell_command("stellar", &[a.clone(), b.clone()]); + assert!(s.contains("stellar contract build --package=another")); + assert!(s.contains("&&")); + assert!(s.contains("stellar contract build --package=hello-world")); + // A value with a space must be quoted so it stays one token. + assert!( + s.contains("'--meta=note=added on build'") + || s.contains("\"--meta=note=added on build\""), + "expected the spaced token to be quoted, got: {s}" + ); + + // An older image's binary (`soroban`) is used verbatim in the chain. + let s = compose_shell_command("soroban", &[a, b]); + assert!(s.contains("soroban contract build --package=another")); + assert!(s.contains("soroban contract build --package=hello-world")); + assert!(!s.contains("stellar")); + } + + #[test] + fn compose_invocation_single_package_uses_default_entrypoint() { + let cmds = vec![vec![ + "contract".to_string(), + "build".to_string(), + "--meta=field=value".to_string(), + "--optimize".to_string(), + ]]; + let (entrypoint, post_image, reproduce) = compose_invocation( + "docker", + "", + "/ws:/source", + " -e CARGO_TARGET_DIR=/source/target", + "docker.io/stellar/stellar-cli:latest", + "stellar", + &cmds, + ); + assert!(entrypoint.is_none()); + assert_eq!(post_image, cmds[0]); + // The bind mount and image ref are shell-escaped (single-quoted here + // because of the `:`), so the line copy-pastes back to the same argv. + assert_eq!( + reproduce, + "docker run --rm -v '/ws:/source' -w /source \ + -e CARGO_TARGET_DIR=/source/target \ + 'docker.io/stellar/stellar-cli:latest' \ + contract build --meta=field=value --optimize" + ); + } + + #[test] + fn compose_invocation_escapes_spaced_bind_and_image() { + // A workspace path with a space must stay one token in the copy-pasteable + // reproduce line, as must a metacharacter-laden image ref. + let cmds = vec![vec!["contract".to_string(), "build".to_string()]]; + let (_entrypoint, _post_image, reproduce) = compose_invocation( + "docker", + "", + "/Users/me/My Project/ws:/source", + "", + "my registry/img:tag", + "stellar", + &cmds, + ); + // The whole `-v` value and the image ref round-trip as single tokens. + let tokens = shlex::split(&reproduce).expect("reproduce line must be valid shell"); + assert!(tokens.contains(&"/Users/me/My Project/ws:/source".to_string())); + assert!(tokens.contains(&"my registry/img:tag".to_string())); + } + + #[test] + fn compose_invocation_includes_engine_prefix_verbatim() { + // The prefix (which may carry `-H `) is rendered before `run`, so a + // copy-paste hits the same daemon the CLI used. + let cmds = vec![vec!["contract".to_string(), "build".to_string()]]; + let (_entrypoint, _post_image, reproduce) = compose_invocation( + "docker -H ssh://host", + "", + "/ws:/source", + "", + "img:tag", + "stellar", + &cmds, + ); + assert!( + reproduce.starts_with( + "docker -H ssh://host run --rm -v '/ws:/source' -w /source 'img:tag' contract build" + ), + "got: {reproduce}" + ); + } + + #[test] + fn compose_invocation_multi_package_chains_through_shell() { + let cmds = vec![ + vec![ + "contract".to_string(), + "build".to_string(), + "--package=a".to_string(), + ], + vec![ + "contract".to_string(), + "build".to_string(), + "--package=b".to_string(), + ], + ]; + let (entrypoint, post_image, reproduce) = compose_invocation( + "container", + " --cpus 2", + "/ws:/source", + "", + "img:tag", + "stellar", + &cmds, + ); + assert_eq!(entrypoint, Some("/bin/sh")); + assert_eq!(post_image[0], "-c"); + assert_eq!( + post_image[1], + "stellar contract build --package=a && stellar contract build --package=b" + ); + assert!(reproduce + .starts_with("container run --rm --cpus 2 -v '/ws:/source' -w /source --entrypoint /bin/sh 'img:tag' -c ")); + // The chained script is passed as one shell-quoted argument. + assert!(reproduce.contains( + "'stellar contract build --package=a && stellar contract build --package=b'" + )); + + // A pre-21.0.0 image's `soroban` binary flows through to the chain. + let (_entrypoint, post_image, reproduce) = compose_invocation( + "container", + "", + "/ws:/source", + "", + "img:tag", + "soroban", + &cmds, + ); + assert_eq!( + post_image[1], + "soroban contract build --package=a && soroban contract build --package=b" + ); + assert!(reproduce.contains( + "'soroban contract build --package=a && soroban contract build --package=b'" + )); + } + + #[test] + fn parse_cli_version_reads_first_line_only() { + // Old `soroban` binary: must take 0.1.2, not the `23` on the next line. + assert_eq!( + parse_cli_version( + "soroban-cli 0.1.2 (70110a1eb3e3af0bee4ac93d005eb2614e9c8e85)\n\ + soroban-env-interface-version: 23\n" + ), + Some(Version::parse("0.1.2").unwrap()) + ); + // Current `stellar` binary: must take 27.1.0, not the stellar-xdr 22.1.0. + assert_eq!( + parse_cli_version( + "stellar 27.1.0 (abc123)\n\ + stellar-xdr 22.1.0 (def456)\n\ + xdr curr (ghi789)\n" + ), + Some(Version::parse("27.1.0").unwrap()) + ); + // No trailing git hash. + assert_eq!( + parse_cli_version("stellar 26.1.0\n"), + Some(Version::parse("26.1.0").unwrap()) + ); + assert_eq!(parse_cli_version(""), None); + assert_eq!(parse_cli_version("not a version\n"), None); + } + + #[test] + fn probe_value_splits_tagged_combined_output() { + let stdout = "BIN:stellar\n\ + VERSION:stellar 27.1.0 (abc123)\n\ + TOOLCHAIN:1.97.1-aarch64-unknown-linux-gnu (default)\n"; + assert_eq!(probe_value(stdout, "BIN:"), "stellar"); + assert_eq!(probe_value(stdout, "VERSION:"), "stellar 27.1.0 (abc123)"); + assert_eq!( + probe_value(stdout, "TOOLCHAIN:"), + "1.97.1-aarch64-unknown-linux-gnu (default)" + ); + // The tagged values feed the same parsers used on standalone output. + assert_eq!( + parse_cli_version(probe_value(stdout, "VERSION:")), + Some(Version::parse("27.1.0").unwrap()) + ); + assert_eq!( + parse_default_toolchain(probe_value(stdout, "TOOLCHAIN:")).as_deref(), + Some("1.97.1-aarch64-unknown-linux-gnu") + ); + // A missing tag (fact not gathered) yields an empty value. + assert_eq!(probe_value("BIN:soroban\n", "TOOLCHAIN:"), ""); + } + + #[test] + fn parse_default_toolchain_extracts_name() { + assert_eq!( + parse_default_toolchain("1.97.1-aarch64-unknown-linux-gnu (default)\n").as_deref(), + Some("1.97.1-aarch64-unknown-linux-gnu") + ); + assert_eq!( + parse_default_toolchain("stable-x86_64-unknown-linux-gnu (default)").as_deref(), + Some("stable-x86_64-unknown-linux-gnu") + ); + assert_eq!(parse_default_toolchain("").as_deref(), None); + assert_eq!(parse_default_toolchain(" \n").as_deref(), None); + } + + #[test] + fn escape_args_round_trips_spaced_tokens() { + let cmd = vec![ + "contract".to_string(), + "build".to_string(), + "--meta=note=added on build".to_string(), + ]; + let s = escape_args(&cmd); + let tokens = shlex::split(&s).expect("reproduce args must be valid shell"); + assert_eq!( + tokens, + vec!["contract", "build", "--meta=note=added on build"] + ); + } +} diff --git a/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs index aec0526641..596fe68a6a 100644 --- a/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs +++ b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs @@ -194,7 +194,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 @@ -287,7 +287,7 @@ impl Cmd { Ok(()) } - fn resolve_contracts( + async fn resolve_contracts( &self, global_args: &global::Args, ) -> Result, Error> { @@ -310,7 +310,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 26d808edba..a61120b8db 100644 --- a/cmd/soroban-cli/src/commands/contract/upload.rs +++ b/cmd/soroban-cli/src/commands/contract/upload.rs @@ -145,7 +145,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 @@ -182,7 +182,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 { @@ -191,7 +191,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/mod.rs b/cmd/soroban-cli/src/commands/mod.rs index 9367463625..4d82ce0890 100644 --- a/cmd/soroban-cli/src/commands/mod.rs +++ b/cmd/soroban-cli/src/commands/mod.rs @@ -32,6 +32,7 @@ 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"; const ABOUT: &str = "Work seamlessly with Stellar accounts, contracts, and assets from the command line. From 2da8e75f2093b2635d55f078a05f31c5c2e49e12 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 12 Aug 2026 14:36:11 -0300 Subject: [PATCH 2/4] Make no-image-pull fail when image is missing. --- .../src/commands/container/shared.rs | 27 ++++++++++++ .../src/commands/contract/build/container.rs | 43 ++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/cmd/soroban-cli/src/commands/container/shared.rs b/cmd/soroban-cli/src/commands/container/shared.rs index 3bbd073918..ac81657c23 100644 --- a/cmd/soroban-cli/src/commands/container/shared.rs +++ b/cmd/soroban-cli/src/commands/container/shared.rs @@ -228,6 +228,18 @@ impl Args { cmd } + /// Inspect a locally-present image, used to assert an image exists without + /// contacting the registry. Both engines group this under `image inspect` and + /// exit non-zero when the image is absent locally. Neither `docker run` nor + /// `container run` offers a portable "never pull" policy (Apple's CLI has no + /// `--pull` flag), so `--no-image-pull` is enforced with this precheck + /// instead of relying on the engine's implicit pull behavior. + pub(crate) fn image_inspect_command(&self, image: &str) -> Command { + let mut cmd = self.base_command(); + cmd.args(["image", "inspect", image]); + cmd + } + pub(crate) fn run_command(&self, name: &str, ports: &[String]) -> Command { let mut cmd = self.base_command(); cmd.args(["run", "-d", "--rm", "--name", name]); @@ -403,6 +415,21 @@ mod test { assert_eq!(args_of(&cmd), ["image", "pull", "img:tag"]); } + #[test] + fn docker_image_inspect_uses_image_inspect() { + let cmd = args(None, None).image_inspect_command("img:tag"); + assert_eq!(program_of(&cmd), "docker"); + assert_eq!(args_of(&cmd), ["image", "inspect", "img:tag"]); + } + + #[test] + fn apple_image_inspect_uses_image_inspect_and_ignores_host() { + let cmd = + args(Some("ssh://host"), Some(Engine::AppleContainer)).image_inspect_command("img:tag"); + assert_eq!(program_of(&cmd), "container"); + assert_eq!(args_of(&cmd), ["image", "inspect", "img:tag"]); + } + #[test] fn docker_run_passes_host_as_h_flag() { let cmd = diff --git a/cmd/soroban-cli/src/commands/contract/build/container.rs b/cmd/soroban-cli/src/commands/contract/build/container.rs index c1a0ecef43..fbb3bd21db 100644 --- a/cmd/soroban-cli/src/commands/contract/build/container.rs +++ b/cmd/soroban-cli/src/commands/contract/build/container.rs @@ -47,6 +47,12 @@ pub enum Error { #[error("could not pull image {image}")] PullImageFailed { image: String }, + #[error( + "image {image} is not present locally and --no-image-pull was set; \ + pull it first or drop --no-image-pull to fetch it" + )] + ImageNotPresent { image: String }, + #[error( "could not determine the image's default Rust toolchain via `rustup default`; \ the image must provide rustup so the build toolchain can be pinned" @@ -87,8 +93,20 @@ pub async fn run( // Pull unless printing (nothing runs) or the user opted out with // `--no-image-pull` to use a locally present image (offline / air-gapped // verification, or a locally-built image that was never pushed). - if !print_only && !cmd.no_image_pull { - pull_image(&docker, image, print).await?; + // + // An explicit `pull` refreshes a moving tag (`docker run`'s default + // `--pull=missing` only fetches a *missing* image, never re-pulling an + // existing tag). With `--no-image-pull` we skip that refresh — but neither + // engine offers a portable "never pull on run" policy (Apple's `container` + // has no `--pull` flag), and `docker run` would still auto-pull a *missing* + // image, defeating the flag. So enforce it ourselves: assert the image is + // present locally up front and fail fast when it isn't. + if !print_only { + if cmd.no_image_pull { + ensure_image_present(&docker, image).await?; + } else { + pull_image(&docker, image, print).await?; + } } // Gather everything we need to know about the image in one throwaway @@ -316,6 +334,27 @@ fn forwarded_build_args( args } +/// Assert the image is already present locally, for `--no-image-pull`. Runs +/// `image inspect`, which exits non-zero when the image is absent, and never +/// contacts the registry — so a locally-tagged (possibly stale) image is +/// accepted, which is exactly the flag's intent. Output is discarded; only the +/// exit status matters. +async fn ensure_image_present(docker: &shared::Args, image: &str) -> Result<(), Error> { + let status = docker + .image_inspect_command(image) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await + .map_err(|e| docker.io_error(e))?; + if !status.success() { + return Err(Error::ImageNotPresent { + image: image.to_string(), + }); + } + Ok(()) +} + async fn pull_image(docker: &shared::Args, image: &str, print: &Print) -> Result<(), Error> { print.infoln(format!("Pulling image {image}")); let (stdout, stderr) = if print.quiet { From be9643dc44bd7c0642763bc06276100d92fd685a Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 12 Aug 2026 15:42:19 -0300 Subject: [PATCH 3/4] Test image build selects package by manifest path. --- cmd/crates/soroban-test/tests/it/build.rs | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/cmd/crates/soroban-test/tests/it/build.rs b/cmd/crates/soroban-test/tests/it/build.rs index 7aa9058f3a..dddf87fd8e 100644 --- a/cmd/crates/soroban-test/tests/it/build.rs +++ b/cmd/crates/soroban-test/tests/it/build.rs @@ -91,6 +91,34 @@ fn build_with_image_print_commands_only_single_package() { ); } +#[test] +fn build_with_image_selects_package_by_manifest_path() { + // With `--image` and a `--manifest-path` pointing at a single member, only + // that package is built — mirroring the local build's package selection — + // instead of chaining every default-member cdylib. So it takes the + // single-package form (image's default entrypoint, no `/bin/sh` chain). + let sandbox = TestEnv::default(); + let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/workspace/"); + sandbox + .new_assert_cmd("contract") + .current_dir(fixture_path) + .arg("build") + .arg("--image") + .arg("docker.io/stellar/stellar-cli:latest") + .arg(manifest_path_arg(&add_path())) + .arg("--print-commands-only") + .assert() + .success() + .stdout( + predicate::str::contains("--package=add") + .and(predicate::str::contains("--package=call").not()) + .and(predicate::str::contains("--package=add2").not()) + .and(predicate::str::contains("&&").not()) + .and(predicate::str::contains("--entrypoint").not()), + ); +} + #[test] fn build_package_by_name() { let sandbox = TestEnv::default(); From c69ec2d62efe141852a94505f3384eecc6a07387 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 12 Aug 2026 16:17:05 -0300 Subject: [PATCH 4/4] Pick freshest wasm when collecting build output. --- .../src/commands/contract/build/container.rs | 76 +++++++++++++++++-- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/build/container.rs b/cmd/soroban-cli/src/commands/contract/build/container.rs index fbb3bd21db..0c72beeaea 100644 --- a/cmd/soroban-cli/src/commands/contract/build/container.rs +++ b/cmd/soroban-cli/src/commands/contract/build/container.rs @@ -717,6 +717,24 @@ async fn wait_for_termination_signal() { let _ = tokio::signal::ctrl_c().await; } +/// Among candidate artifact paths, return the one that exists and was modified +/// most recently. Probing by existence alone can return a stale wasm left by an +/// earlier build into a different target-triple dir; the freshest file is the +/// one the current build just wrote. Returns `None` when none exist. An +/// unreadable mtime is treated as the epoch, so such a file is only chosen when +/// it's the sole candidate. +fn newest_existing_artifact(candidates: &[PathBuf]) -> Option { + candidates + .iter() + .filter(|p| p.exists()) + .max_by_key(|p| { + std::fs::metadata(p) + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH) + }) + .cloned() +} + /// Collect the built wasm from the mounted `target/`. Because the working tree /// was bind-mounted, the container writes artifacts straight to the host under /// `/target///`. The container's rust toolchain @@ -748,15 +766,19 @@ fn collect_built_contracts( let file = format!("{}.wasm", p.name.replace('-', "_")); // The container may build for either wasm target depending on its rust - // version; fall back to the current host default for the reported path. - let src = [WASM_TARGET, WASM_TARGET_OLD] + // version, so probe both triple dirs. Pick the *freshest* rather than the + // first that exists: an earlier build into the other triple can leave a + // stale wasm behind, and selecting by existence alone would return it. + // Fall back to the current host default for the reported path when the + // build produced nothing. + let candidates: Vec = [WASM_TARGET, WASM_TARGET_OLD] .iter() .map(|triple| target_root.join(triple).join(&cmd.profile).join(&file)) - .find(|path| path.exists()) - .unwrap_or_else(|| { - let triple = get_wasm_target().unwrap_or_else(|_| WASM_TARGET.to_string()); - target_root.join(triple).join(&cmd.profile).join(&file) - }); + .collect(); + let src = newest_existing_artifact(&candidates).unwrap_or_else(|| { + let triple = get_wasm_target().unwrap_or_else(|_| WASM_TARGET.to_string()); + target_root.join(triple).join(&cmd.profile).join(&file) + }); let path = if let Some(out_dir) = &cmd.out_dir { std::fs::create_dir_all(out_dir).map_err(super::Error::CreatingOutDir)?; @@ -1105,6 +1127,46 @@ mod tests { assert_eq!(parse_default_toolchain(" \n").as_deref(), None); } + #[test] + fn newest_existing_artifact_prefers_freshest_not_first() { + use std::time::{Duration, SystemTime}; + let dir = tempfile::tempdir().unwrap(); + let old = dir.path().join("old.wasm"); + let new = dir.path().join("new.wasm"); + std::fs::write(&old, b"old").unwrap(); + std::fs::write(&new, b"new").unwrap(); + // Pin mtimes so the ordering is unambiguous regardless of filesystem + // timestamp resolution. + let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000); + std::fs::OpenOptions::new() + .write(true) + .open(&old) + .unwrap() + .set_modified(base) + .unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(&new) + .unwrap() + .set_modified(base + Duration::from_mins(1)) + .unwrap(); + + // `old` is listed first, but the fresher `new` must win — selection is by + // mtime, not list position (the staleness bug this guards against). + assert_eq!( + newest_existing_artifact(&[old.clone(), new.clone()]), + Some(new) + ); + // A non-existent candidate is skipped; the one real file is returned. + let missing = dir.path().join("missing.wasm"); + assert_eq!( + newest_existing_artifact(&[missing.clone(), old.clone()]), + Some(old) + ); + // Nothing exists → None (caller falls back to the host-default path). + assert_eq!(newest_existing_artifact(&[missing]), None); + } + #[test] fn escape_args_round_trips_spaced_tokens() { let cmd = vec![