Skip to main content

soroban_cli/commands/contract/build/
container.rs

1//! Build a contract inside a container image.
2//!
3//! Triggered by `stellar contract build --image <ref>`: instead of compiling
4//! locally, the working tree is bind-mounted into the given container image at
5//! `/source` and `stellar contract build` is run there. The resulting wasm is
6//! written into the mounted `target/` directory and therefore lands on the host
7//! directly. Any image ref is accepted — a tag (`:latest`) or a digest.
8//!
9//! This is deliberately standalone: no source archive, no clean-git-tree
10//! requirement, no reproducibility metadata. It reuses the container engine
11//! abstraction in [`crate::commands::container::shared`], so `--engine`,
12//! `--docker-host`, and the default engine set by `stellar container use` all
13//! apply.
14
15use std::path::{Path, PathBuf};
16use std::process::Stdio;
17
18use cargo_metadata::MetadataCommand;
19use path_slash::PathExt as _;
20use semver::Version;
21
22use crate::commands::{container::shared, global};
23use crate::print::Print;
24
25use super::{get_wasm_target, BuiltContract, Cmd, WASM_TARGET, WASM_TARGET_OLD};
26
27/// First CLI release whose `contract build` accepts `--locked` (added in cli
28/// v25.2.0). Older images reject it, so it's dropped (with a warning) on anything
29/// older, matching the version detected from the image's own `version` output.
30const LOCKED_MIN: &str = "25.2.0";
31
32/// First CLI release whose `contract build` has the `--optimize` flag at all.
33/// Older images reject it, so — since optimization is on by default — this is the
34/// effective minimum supported image. We probe the image's `version` and skip the
35/// flag (with a warning) on anything older.
36const OPTIMIZE_FLAG_MIN: &str = "23.2.0";
37
38/// First CLI release whose `contract build` accepts `--optimize=false` as an
39/// explicit value. Images between [`OPTIMIZE_FLAG_MIN`] and this default to *not*
40/// optimizing, so for them we forward nothing to get an unoptimized build.
41const OPTIMIZE_NEW_SYNTAX_MIN: &str = "26.1.0";
42
43#[derive(thiserror::Error, Debug)]
44pub enum Error {
45    #[error(transparent)]
46    Engine(#[from] shared::Error),
47
48    #[error("could not pull image {image}")]
49    PullImageFailed { image: String },
50
51    #[error(
52        "could not determine the image's default Rust toolchain via `rustup default`; \
53         the image must provide rustup so the build toolchain can be pinned"
54    )]
55    ToolchainProbeFailed,
56
57    #[error("cargo metadata failed: {0}")]
58    Metadata(#[from] cargo_metadata::Error),
59
60    #[error("container build exited with status {status}. To reproduce manually:\n  {command}")]
61    ContainerExit { status: i64, command: String },
62
63    #[error("build interrupted; stopped the build container")]
64    Interrupted,
65}
66
67pub async fn run(
68    cmd: &Cmd,
69    _global_args: &global::Args,
70    print: &Print,
71) -> Result<Vec<BuiltContract>, super::Error> {
72    let image = cmd
73        .image
74        .as_deref()
75        .expect("container::run is only called when --image is set");
76
77    let docker = cmd.container_args.clone();
78    docker.warn_if_host_ignored(print);
79
80    // Bind-mount the workspace root so every crate is available and relative
81    // manifest paths resolve inside the container. `cargo metadata` is resolved
82    // once here and reused for workspace root, package selection, and artifact
83    // collection, so a large or networked workspace pays a single subprocess.
84    let md = metadata(cmd).map_err(Error::from)?;
85    let workspace_root = md.workspace_root.clone().into_std_path_buf();
86
87    // With `--print-commands-only` nothing runs, so don't pull, probe, or build;
88    // just render the run command against a current image below.
89    let print_only = cmd.print_commands_only;
90
91    // By default the build uses the image already present locally and doesn't
92    // pull, matching `docker run` (whose default `--pull=missing` only fetches a
93    // *missing* image, never re-pulling an existing tag). This keeps a
94    // locally-built or digest-pinned image as-is. `--pull` opts in to an explicit
95    // `pull` up front to refresh a moving tag to its newest image. Nothing is
96    // pulled when only printing the command, since nothing runs.
97    if !print_only && cmd.pull {
98        pull_image(&docker, image, print).await?;
99    }
100
101    // Gather everything we need to know about the image in one throwaway
102    // container (binary name, CLI version, default rustup toolchain), so slow or
103    // remote engines pay a single round-trip instead of one per fact. When only
104    // printing the command we can't probe (that would run a container), so a
105    // current image is assumed and the toolchain pin is omitted.
106    let probe = if print_only {
107        None
108    } else {
109        Some(probe_image(image, &docker).await?)
110    };
111    // The CLI version drives flag gating; a probed image that didn't report a
112    // parseable version is treated as current (with a warning).
113    let cli_version = match &probe {
114        Some(p) if p.version.is_none() => {
115            print.warnln("Could not probe container cli version; assuming a current image");
116            None
117        }
118        Some(p) => p.version.clone(),
119        None => None,
120    };
121    let at_least = |min: &str| {
122        cli_version
123            .as_ref()
124            .is_none_or(|v| *v >= Version::parse(min).unwrap())
125    };
126    // `--locked` was added in v25.2.0, the `--optimize` flag in v23.2.0, and its
127    // explicit `--optimize=false` value in v26.1.0.
128    let supports_locked = at_least(LOCKED_MIN);
129    let supports_optimize_flag = at_least(OPTIMIZE_FLAG_MIN);
130    let supports_optimize_false = at_least(OPTIMIZE_NEW_SYNTAX_MIN);
131    if cmd.locked && !supports_locked {
132        print.warnln(
133            "The build image's `contract build` does not support --locked; \
134             building without it.",
135        );
136    }
137    if cmd.build_args.optimize && !supports_optimize_flag {
138        print.warnln(format!(
139            "The build image's `contract build` does not support --optimize \
140             (added in cli v{OPTIMIZE_FLAG_MIN}); building without optimization.",
141        ));
142    }
143
144    // Build once per package so workspaces with several cdylibs all get built;
145    // an explicit `--package` wins, otherwise the default-member cdylibs are
146    // inferred exactly like a local build.
147    let packages = resolve_packages(cmd, &md);
148    if cmd.package.is_none() && !packages.is_empty() {
149        print.infoln(format!("Building packages: {}", packages.join(", ")));
150    }
151    let targets: Vec<Option<&str>> = if packages.is_empty() {
152        vec![None]
153    } else {
154        packages.iter().map(|p| Some(p.as_str())).collect()
155    };
156    let container_cmds: Vec<Vec<String>> = targets
157        .iter()
158        .map(|target| {
159            forwarded_build_args(
160                cmd,
161                &workspace_root,
162                *target,
163                supports_locked,
164                supports_optimize_flag,
165                supports_optimize_false,
166            )
167        })
168        .collect();
169
170    // Reset the target dir to a known location under the mount, independent of
171    // any mounted `.cargo/config` `build.target-dir` or image env, so we always
172    // know where to collect artifacts.
173    let mut env: Vec<String> = vec!["CARGO_TARGET_DIR=/source/target".to_string()];
174
175    // Pin RUSTUP_TOOLCHAIN to the image's own default toolchain so a
176    // `rust-toolchain.toml` in the mounted source can't redirect the build to a
177    // different toolchain — which rustup would then try to install (needing
178    // network access and possibly lacking the wasm target). An empty
179    // RUSTUP_TOOLCHAIN would *not* achieve this: rustup treats it as unset and
180    // still honors rust-toolchain.toml, so the probe reports the concrete
181    // toolchain name (guaranteed non-empty; `probe_image` hard-fails otherwise).
182    // Skipped when only printing the command, where nothing is probed.
183    if let Some(p) = &probe {
184        print.infoln(format!("Using Rust toolchain {}", p.toolchain));
185        env.push(format!("RUSTUP_TOOLCHAIN={}", p.toolchain));
186    }
187
188    // Chaining several builds through `/bin/sh` invokes the CLI by name, which
189    // differs across images (`soroban` before v21.0.0, `stellar` since). The
190    // single-build path uses the image's entrypoint and doesn't care. Default to
191    // `stellar` when not probed (print-only).
192    let bin = probe
193        .as_ref()
194        .map_or_else(|| "stellar".to_string(), |p| p.bin.clone());
195
196    run_in_container(
197        image,
198        &workspace_root,
199        &container_cmds,
200        &env,
201        &docker,
202        &cmd.run_args,
203        &bin,
204        print,
205        print_only,
206    )
207    .await?;
208
209    // Nothing was built when only printing the command.
210    if print_only {
211        return Ok(Vec::new());
212    }
213
214    collect_built_contracts(cmd, &md, &workspace_root)
215}
216
217fn metadata(cmd: &Cmd) -> Result<cargo_metadata::Metadata, cargo_metadata::Error> {
218    let mut mc = MetadataCommand::new();
219    mc.no_deps();
220    if let Some(p) = &cmd.manifest_path {
221        mc.manifest_path(p);
222    }
223    mc.exec()
224}
225
226/// Resolve the packages to build. An explicit `--package` wins; otherwise the
227/// default-member crates that build a cdylib, mirroring the local build's
228/// package selection. May be empty (no cdylib default members), in which case
229/// the caller falls back to a single no-`--package` build.
230fn resolve_packages(cmd: &Cmd, md: &cargo_metadata::Metadata) -> Vec<String> {
231    if let Some(pkg) = &cmd.package {
232        return vec![pkg.clone()];
233    }
234    let mut names: Vec<String> = md
235        .packages
236        .iter()
237        .filter(|p| md.workspace_default_members.contains(&p.id))
238        .filter(|p| {
239            p.targets
240                .iter()
241                .any(|t| t.crate_types.iter().any(|c| c == "cdylib"))
242        })
243        .map(|p| p.name.clone())
244        .collect();
245    names.sort();
246    names.dedup();
247    names
248}
249
250/// The `contract build …` argv forwarded to the container, mirroring the local
251/// build's flags. `--manifest-path` is relativized against the workspace root so
252/// it's valid inside `/source`. `--out-dir` is deliberately omitted — artifacts
253/// are collected on the host from the mounted `target/`.
254///
255/// `supports_locked`: whether the container's `contract build` accepts `--locked`
256/// (added in cli 25.2.0). When false, the user's `--locked` is dropped rather
257/// than forwarded to an image that would reject it.
258///
259/// `supports_optimize_flag`: whether the container's cli has the `--optimize`
260/// flag at all (added in cli 23.2.0). When false, nothing about optimize is
261/// forwarded — the flag would be rejected as unknown.
262///
263/// `supports_optimize_false`: whether the container's cli accepts
264/// `--optimize=false` (added in cli 26.1.0). When false and the user disabled
265/// optimization, nothing is forwarded — the older cli defaults to not
266/// optimizing, and passing `--optimize=false` there would fail.
267fn forwarded_build_args(
268    cmd: &Cmd,
269    workspace_root: &Path,
270    package: Option<&str>,
271    supports_locked: bool,
272    supports_optimize_flag: bool,
273    supports_optimize_false: bool,
274) -> Vec<String> {
275    let mut args = vec!["contract".to_string(), "build".to_string()];
276
277    if cmd.locked && supports_locked {
278        args.push("--locked".to_string());
279    }
280    if let Some(path) = &cmd.manifest_path {
281        let abs = std::path::absolute(path).unwrap_or_else(|_| path.clone());
282        let rel = abs
283            .strip_prefix(workspace_root)
284            .map(Path::to_path_buf)
285            .unwrap_or(abs);
286        args.push(format!("--manifest-path={}", rel.to_slash_lossy()));
287    }
288    if cmd.profile != "release" {
289        args.push(format!("--profile={}", cmd.profile));
290    }
291    if let Some(features) = &cmd.features {
292        args.push(format!("--features={features}"));
293    }
294    if cmd.all_features {
295        args.push("--all-features".to_string());
296    }
297    if cmd.no_default_features {
298        args.push("--no-default-features".to_string());
299    }
300    if let Some(pkg) = package {
301        args.push(format!("--package={pkg}"));
302    }
303    for (k, v) in &cmd.build_args.meta {
304        args.push(format!("--meta={k}={v}"));
305    }
306    // Optimization is forwarded per the image's cli version. To enable it, bare
307    // `--optimize` on images >= v23.2.0 (older images lack the flag entirely, so
308    // forward nothing). To disable it, `--optimize=false` on images >= v26.1.0;
309    // older ones default to not optimizing, so forwarding nothing matches.
310    if cmd.build_args.optimize {
311        if supports_optimize_flag {
312            args.push("--optimize".to_string());
313        }
314    } else if supports_optimize_false {
315        args.push("--optimize=false".to_string());
316    }
317
318    args
319}
320
321async fn pull_image(docker: &shared::Args, image: &str, print: &Print) -> Result<(), Error> {
322    print.infoln(format!("Pulling image {image}"));
323    let (stdout, stderr) = if print.quiet {
324        (Stdio::null(), Stdio::null())
325    } else {
326        (Stdio::inherit(), Stdio::inherit())
327    };
328    let status = docker
329        .pull_command(image)
330        .stdout(stdout)
331        .stderr(stderr)
332        .status()
333        .await
334        .map_err(|e| docker.io_error(e))?;
335    if !status.success() {
336        return Err(Error::PullImageFailed {
337            image: image.to_string(),
338        });
339    }
340    Ok(())
341}
342
343/// Run `cmd` in a throwaway `docker run --rm` container (optionally overriding
344/// the entrypoint) and return its captured stdout. stderr and the exit status
345/// are ignored — every probe treats a missing subcommand or unexpected output as
346/// "unsupported".
347async fn run_probe(
348    image: &str,
349    docker: &shared::Args,
350    entrypoint: Option<&str>,
351    cmd: Vec<String>,
352) -> Result<String, Error> {
353    let mut command = docker.base_command();
354    command.args(["run", "--rm"]);
355    if let Some(entrypoint) = entrypoint {
356        command.args(["--entrypoint", entrypoint]);
357    }
358    command.arg(image);
359    command.args(&cmd);
360
361    let output = command.output().await.map_err(|e| docker.io_error(e))?;
362    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
363}
364
365/// Facts probed from the image before building, gathered in one throwaway
366/// container to avoid a round-trip per fact.
367struct ImageProbe {
368    /// CLI binary on the image's PATH — `stellar` (v21.0.0+) or `soroban`
369    /// (older). Used when invoking the CLI by name in the chained multi-build
370    /// command; the single-build path uses the image's entrypoint instead.
371    bin: String,
372    /// Parsed CLI version, or `None` when the image reported no parseable version
373    /// (treated as a current image by the caller).
374    version: Option<Version>,
375    /// The image's default rustup toolchain (e.g.
376    /// `1.97.1-aarch64-unknown-linux-gnu`), pinned into `RUSTUP_TOOLCHAIN`.
377    /// Guaranteed non-empty — the probe hard-fails when it can't be determined.
378    toolchain: String,
379}
380
381/// Probe the image once for everything the build needs: the CLI binary name, its
382/// version, and the default rustup toolchain. Runs a single `/bin/sh` script
383/// (the same `/bin/sh` and `rustup` the multi-build path and toolchain pin
384/// already require) that detects the binary, then reports each fact on its own
385/// tagged line so the combined stdout can be split apart. Hard-fails when no
386/// default toolchain can be determined, rather than building unpinned.
387async fn probe_image(image: &str, docker: &shared::Args) -> Result<ImageProbe, Error> {
388    // Detect the binary first, then run `$bin version` (version on its first
389    // line) and `rustup default` (the toolchain name). Tag each line so we can
390    // pick the values back out regardless of any extra output.
391    let script = "\
392        bin=\"$(command -v stellar >/dev/null 2>&1 && echo stellar || echo soroban)\"\n\
393        printf 'BIN:%s\\n' \"$bin\"\n\
394        printf 'VERSION:%s\\n' \"$(\"$bin\" version 2>/dev/null | head -n1)\"\n\
395        printf 'TOOLCHAIN:%s\\n' \"$(rustup default 2>/dev/null)\"\n";
396    let stdout = run_probe(
397        image,
398        docker,
399        Some("/bin/sh"),
400        vec!["-c".to_string(), script.to_string()],
401    )
402    .await?;
403
404    let bin = match probe_value(&stdout, "BIN:") {
405        "" => "stellar".to_string(),
406        b => b.to_string(),
407    };
408    let version = parse_cli_version(probe_value(&stdout, "VERSION:"));
409    let toolchain = parse_default_toolchain(probe_value(&stdout, "TOOLCHAIN:"))
410        .ok_or(Error::ToolchainProbeFailed)?;
411
412    Ok(ImageProbe {
413        bin,
414        version,
415        toolchain,
416    })
417}
418
419/// Pull the value of a `TAG:value` line out of the combined probe output. Returns
420/// an empty string when the tag is absent (the fact couldn't be gathered).
421fn probe_value<'a>(stdout: &'a str, tag: &str) -> &'a str {
422    stdout
423        .lines()
424        .find_map(|l| l.strip_prefix(tag))
425        .map(str::trim)
426        .unwrap_or_default()
427}
428
429/// Extract the cli version from `version` output. The first line looks like
430/// `stellar 27.1.0 (<hash>)` or `soroban-cli 0.1.2 (<hash>)`; later lines carry
431/// unrelated numbers (`stellar-xdr 22.1.0`, `soroban-env-interface-version: 23`),
432/// so only the first line is considered, taking its first valid-semver token.
433fn parse_cli_version(stdout: &str) -> Option<Version> {
434    stdout
435        .lines()
436        .next()?
437        .split_whitespace()
438        .find_map(|tok| Version::parse(tok).ok())
439}
440
441/// Extract the toolchain name from `rustup default` output, which looks like
442/// `1.97.1-aarch64-unknown-linux-gnu (default)`. Returns `None` when the output
443/// is empty (e.g. the image has no default toolchain or lacks `rustup`).
444fn parse_default_toolchain(stdout: &str) -> Option<String> {
445    stdout.split_whitespace().next().map(str::to_string)
446}
447
448#[allow(clippy::too_many_arguments)]
449async fn run_in_container(
450    image: &str,
451    workspace_root: &Path,
452    container_cmds: &[Vec<String>],
453    env: &[String],
454    docker: &shared::Args,
455    run_args: &shared::RunArgs,
456    bin: &str,
457    print: &Print,
458    print_only: bool,
459) -> Result<(), Error> {
460    let bind = format!("{}:/source", workspace_root.display());
461    // The engine prefix for the reproduce line mirrors `base_command`, including
462    // `-H <host>` so a copy-paste hits the same daemon the CLI used.
463    let prefix = docker.command_prefix();
464
465    // `-e KEY=VALUE` flags, mirrored into the reproduce line below.
466    let mut env_flags = String::new();
467    for e in env {
468        env_flags.push_str(" -e ");
469        env_flags.push_str(&shell_escape::escape(e.as_str().into()));
470    }
471
472    // On Linux, run as the host uid:gid so wasm the container writes into the
473    // bind-mounted `target/` is owned by the invoking user instead of root.
474    // Docker Desktop (macOS) and Apple's `container` map ownership to the host
475    // user already, so this is Linux-only.
476    //
477    // This assumes the image keeps CARGO_HOME/RUSTUP_HOME writable by non-root
478    // users, which the official rust-based image does. An arbitrary `--image`
479    // with root-owned toolchain dirs may fail the build under this uid — a known
480    // limitation of running unofficial images.
481    let user_flags: Vec<String> = current_user_flags();
482
483    // Run flags for the copy-pasteable reproduce line, matching where they're
484    // applied to the spawned command below.
485    let mut run_flags = String::new();
486    for f in run_args.flags().iter().chain(user_flags.iter()) {
487        run_flags.push(' ');
488        run_flags.push_str(&shell_escape::escape(f.as_str().into()));
489    }
490
491    let (entrypoint, post_image, reproduce) = compose_invocation(
492        &prefix,
493        &run_flags,
494        &bind,
495        &env_flags,
496        image,
497        bin,
498        container_cmds,
499    );
500
501    // `--print-commands-only`: emit the run command to stdout (so it's
502    // pipeable) and stop, without touching the engine.
503    if print_only {
504        println!("{reproduce}");
505        return Ok(());
506    }
507
508    print.infoln(format!("Building in {image} (mount {bind})"));
509    print.infoln(format!("Running: {reproduce}"));
510
511    // Name the container so it can be stopped if the CLI is interrupted: the
512    // daemon owns the container, so the client exiting doesn't stop it. Unique
513    // per invocation so concurrent builds don't collide, and kept out of the
514    // reproduce line where a fixed name would clash on re-run.
515    let container_name = format!(
516        "stellar-contract-build-{}-{:08x}",
517        std::process::id(),
518        rand::random::<u32>()
519    );
520
521    let mut command = docker.base_command();
522    command.args(["run", "--rm", "--name", &container_name]);
523    run_args.apply(&mut command);
524    command.args(&user_flags);
525    command.args(["-v", &bind, "-w", "/source"]);
526    for e in env {
527        command.args(["-e", e]);
528    }
529    if let Some(entrypoint) = entrypoint {
530        command.args(["--entrypoint", entrypoint]);
531    }
532    command.arg(image);
533    command.args(&post_image);
534
535    // Stream the build output straight to the terminal (matching a local build);
536    // `quiet` discards it.
537    let (stdout, stderr) = if print.quiet {
538        (Stdio::null(), Stdio::null())
539    } else {
540        (Stdio::inherit(), Stdio::inherit())
541    };
542    command.stdout(stdout).stderr(stderr);
543
544    let mut child = command.spawn().map_err(|e| docker.io_error(e))?;
545
546    // Race the build against any catchable termination signal. On a signal, kill
547    // the named container (best-effort) so it doesn't outlive the CLI, kill the
548    // engine client we spawned, then surface the interruption.
549    let status = tokio::select! {
550        result = child.wait() => result.map_err(|e| docker.io_error(e))?,
551        () = wait_for_termination_signal() => {
552            print.warnln("Interrupted; stopping build container");
553            let _ = docker.kill_command(&container_name).output().await;
554            let _ = child.start_kill();
555            return Err(Error::Interrupted);
556        }
557    };
558    if !status.success() {
559        return Err(Error::ContainerExit {
560            status: status.code().unwrap_or(-1).into(),
561            command: reproduce,
562        });
563    }
564
565    Ok(())
566}
567
568/// `--user <uid>:<gid>` for the current process on Linux, so container-written
569/// artifacts on bind mounts are owned by the invoking user rather than root.
570/// Empty on every other platform, where the engine's VM maps ownership to the
571/// host user already.
572#[cfg(target_os = "linux")]
573fn current_user_flags() -> Vec<String> {
574    let uid = rustix::process::getuid().as_raw();
575    let gid = rustix::process::getgid().as_raw();
576    vec!["--user".to_string(), format!("{uid}:{gid}")]
577}
578
579#[cfg(not(target_os = "linux"))]
580fn current_user_flags() -> Vec<String> {
581    Vec::new()
582}
583
584/// Build the run invocation: the optional entrypoint override, the args after
585/// the image, and a copy-pasteable reproduce line (also what
586/// `--print-commands-only` emits). One package runs the image's default
587/// entrypoint directly; several override the entrypoint to `/bin/sh` and chain
588/// the builds (invoking the CLI by `bin` name) so they share one container (and
589/// its crates download / compiled deps / `target/`).
590fn compose_invocation(
591    prefix: &str,
592    run_flags: &str,
593    bind: &str,
594    env_flags: &str,
595    image: &str,
596    bin: &str,
597    container_cmds: &[Vec<String>],
598) -> (Option<&'static str>, Vec<String>, String) {
599    // The reproduce line is documented as copy-pasteable, so escape the bind
600    // mount (which embeds the workspace path) and image ref like every other
601    // token; a path with a space or shell metacharacter must still round-trip.
602    let bind = shell_escape::escape(bind.into());
603    let image = shell_escape::escape(image.into());
604    if container_cmds.len() > 1 {
605        let chain = compose_shell_command(bin, container_cmds);
606        let reproduce = format!(
607            "{prefix} run --rm{run_flags} -v {bind} -w /source{env_flags} --entrypoint /bin/sh {image} -c {}",
608            shell_escape::escape(chain.clone().into())
609        );
610        (Some("/bin/sh"), vec!["-c".to_string(), chain], reproduce)
611    } else {
612        let cmd = container_cmds.first().cloned().unwrap_or_default();
613        let reproduce = format!(
614            "{prefix} run --rm{run_flags} -v {bind} -w /source{env_flags} {image} {}",
615            escape_args(&cmd)
616        );
617        (None, cmd, reproduce)
618    }
619}
620
621/// Render the per-package `<bin> contract build …` commands into a single
622/// `sh -c` script (`<bin> … && <bin> …`), shell-escaping every token so values
623/// with spaces survive. `bin` is the container's CLI binary (`soroban` or
624/// `stellar`).
625fn compose_shell_command(bin: &str, cmds: &[Vec<String>]) -> String {
626    cmds.iter()
627        .map(|cmd| {
628            std::iter::once(bin)
629                .chain(cmd.iter().map(String::as_str))
630                .map(|tok| shell_escape::escape(tok.into()).into_owned())
631                .collect::<Vec<_>>()
632                .join(" ")
633        })
634        .collect::<Vec<_>>()
635        .join(" && ")
636}
637
638/// Shell-escape each token of a single-package command for the reproduce line so
639/// a copy-paste round-trips back to the same argv.
640fn escape_args(cmd: &[String]) -> String {
641    cmd.iter()
642        .map(|tok| shell_escape::escape(tok.into()).into_owned())
643        .collect::<Vec<_>>()
644        .join(" ")
645}
646
647/// Resolve once the process receives any catchable signal that would otherwise
648/// terminate it, so the caller can stop the build container before exiting.
649#[cfg(unix)]
650async fn wait_for_termination_signal() {
651    use tokio::signal::unix::{signal, SignalKind};
652
653    let mut sigint = signal(SignalKind::interrupt());
654    let mut sigterm = signal(SignalKind::terminate());
655    let mut sighup = signal(SignalKind::hangup());
656    let mut sigquit = signal(SignalKind::quit());
657
658    tokio::select! {
659        () = recv_signal(&mut sigint) => {},
660        () = recv_signal(&mut sigterm) => {},
661        () = recv_signal(&mut sighup) => {},
662        () = recv_signal(&mut sigquit) => {},
663    }
664}
665
666/// Await one delivery of an installed signal. When the handler failed to install,
667/// never resolves, so it drops out of the `select!` rather than firing spuriously.
668#[cfg(unix)]
669async fn recv_signal(s: &mut std::io::Result<tokio::signal::unix::Signal>) {
670    match s {
671        Ok(s) => {
672            s.recv().await;
673        }
674        Err(_) => std::future::pending().await,
675    }
676}
677
678#[cfg(not(unix))]
679async fn wait_for_termination_signal() {
680    let _ = tokio::signal::ctrl_c().await;
681}
682
683/// Among candidate artifact paths, return the one that exists and was modified
684/// most recently. Probing by existence alone can return a stale wasm left by an
685/// earlier build into a different target-triple dir; the freshest file is the
686/// one the current build just wrote. Returns `None` when none exist. An
687/// unreadable mtime is treated as the epoch, so such a file is only chosen when
688/// it's the sole candidate.
689fn newest_existing_artifact(candidates: &[PathBuf]) -> Option<PathBuf> {
690    candidates
691        .iter()
692        .filter(|p| p.exists())
693        .max_by_key(|p| {
694            std::fs::metadata(p)
695                .and_then(|m| m.modified())
696                .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
697        })
698        .cloned()
699}
700
701/// Collect the built wasm from the mounted `target/`. Because the working tree
702/// was bind-mounted, the container writes artifacts straight to the host under
703/// `<workspace>/target/<triple>/<profile>/`. The container's rust toolchain
704/// decides the target triple, so both known triples are probed. Copies to
705/// `--out-dir` when set.
706fn collect_built_contracts(
707    cmd: &Cmd,
708    md: &cargo_metadata::Metadata,
709    workspace_root: &Path,
710) -> Result<Vec<BuiltContract>, super::Error> {
711    let target_root = workspace_root.join("target");
712
713    let mut out = Vec::new();
714    for p in &md.packages {
715        let is_cdylib = p
716            .targets
717            .iter()
718            .any(|t| t.crate_types.iter().any(|c| c == "cdylib"));
719        if !is_cdylib {
720            continue;
721        }
722        if let Some(name) = &cmd.package {
723            if &p.name != name {
724                continue;
725            }
726        } else if !md.workspace_default_members.contains(&p.id) {
727            continue;
728        }
729
730        let file = format!("{}.wasm", p.name.replace('-', "_"));
731        // The container may build for either wasm target depending on its rust
732        // version, so probe both triple dirs. Pick the *freshest* rather than the
733        // first that exists: an earlier build into the other triple can leave a
734        // stale wasm behind, and selecting by existence alone would return it.
735        // Fall back to the current host default for the reported path when the
736        // build produced nothing.
737        let candidates: Vec<PathBuf> = [WASM_TARGET, WASM_TARGET_OLD]
738            .iter()
739            .map(|triple| target_root.join(triple).join(&cmd.profile).join(&file))
740            .collect();
741        let src = newest_existing_artifact(&candidates).unwrap_or_else(|| {
742            let triple = get_wasm_target().unwrap_or_else(|_| WASM_TARGET.to_string());
743            target_root.join(triple).join(&cmd.profile).join(&file)
744        });
745
746        let path = if let Some(out_dir) = &cmd.out_dir {
747            std::fs::create_dir_all(out_dir).map_err(super::Error::CreatingOutDir)?;
748            let dest = out_dir.join(&file);
749            if src.exists() {
750                std::fs::copy(&src, &dest).map_err(super::Error::CopyingWasmFile)?;
751            }
752            dest
753        } else {
754            src
755        };
756
757        out.push(BuiltContract {
758            name: p.name.clone(),
759            path,
760        });
761    }
762
763    Ok(out)
764}
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769    use crate::commands::contract::build::BuildArgs;
770
771    fn ws() -> PathBuf {
772        // Routed through `std::path::absolute` (as `forwarded_build_args` itself does
773        // for `manifest_path`) so both sides of the `strip_prefix` in
774        // `forwarded_build_args` agree on drive letter/prefix on Windows.
775        std::path::absolute(Path::new("/tmp/ws")).unwrap()
776    }
777
778    #[test]
779    fn forwarded_build_args_defaults() {
780        let cmd = Cmd::default();
781        let args = forwarded_build_args(&cmd, &ws(), None, true, true, true);
782        assert_eq!(args[..2], ["contract".to_string(), "build".to_string()]);
783        // Default optimize=true → bare `--optimize`; no `--locked` unless asked.
784        assert!(args.contains(&"--optimize".to_string()));
785        assert!(!args.iter().any(|a| a == "--locked"));
786        assert!(!args.iter().any(|a| a.starts_with("--package")));
787    }
788
789    #[test]
790    fn forwarded_build_args_locked_and_package() {
791        let cmd = Cmd {
792            locked: true,
793            ..Cmd::default()
794        };
795        let args = forwarded_build_args(&cmd, &ws(), Some("contract-a"), true, true, true);
796        assert!(args.contains(&"--locked".to_string()));
797        assert!(args.contains(&"--package=contract-a".to_string()));
798    }
799
800    #[test]
801    fn forwarded_build_args_drops_locked_when_unsupported() {
802        // User asked for --locked but the image's cli doesn't accept it.
803        let cmd = Cmd {
804            locked: true,
805            ..Cmd::default()
806        };
807        let args = forwarded_build_args(&cmd, &ws(), None, false, true, true);
808        assert!(!args.iter().any(|a| a == "--locked"));
809    }
810
811    #[test]
812    fn forwarded_build_args_omits_optimize_when_flag_unsupported() {
813        // Image older than v23.2.0 has no `--optimize` flag; forward nothing even
814        // though optimize defaults to true.
815        let cmd = Cmd::default();
816        assert!(cmd.build_args.optimize);
817        let args = forwarded_build_args(&cmd, &ws(), None, true, false, false);
818        assert!(!args.iter().any(|a| a.starts_with("--optimize")));
819    }
820
821    #[test]
822    fn forwarded_build_args_features_meta_and_profile() {
823        let cmd = Cmd {
824            profile: "dev".to_string(),
825            features: Some("a,b".to_string()),
826            all_features: true,
827            no_default_features: true,
828            build_args: BuildArgs {
829                meta: vec![
830                    ("home_domain".to_string(), "example.com".to_string()),
831                    ("author".to_string(), "alice".to_string()),
832                ],
833                optimize: false,
834            },
835            ..Cmd::default()
836        };
837        let args = forwarded_build_args(&cmd, &ws(), None, true, true, true);
838        assert!(args.contains(&"--profile=dev".to_string()));
839        assert!(args.contains(&"--features=a,b".to_string()));
840        assert!(args.contains(&"--all-features".to_string()));
841        assert!(args.contains(&"--no-default-features".to_string()));
842        assert!(args.contains(&"--meta=home_domain=example.com".to_string()));
843        assert!(args.contains(&"--meta=author=alice".to_string()));
844        assert!(args.contains(&"--optimize=false".to_string()));
845    }
846
847    #[test]
848    fn forwarded_build_args_optimize_false_old_image_forwards_nothing() {
849        // Old image defaults to not optimizing and rejects `--optimize=false`,
850        // so nothing about optimize is forwarded.
851        let cmd = Cmd {
852            build_args: BuildArgs {
853                optimize: false,
854                ..BuildArgs::default()
855            },
856            ..Cmd::default()
857        };
858        let args = forwarded_build_args(&cmd, &ws(), None, true, true, false);
859        assert!(!args.iter().any(|a| a.starts_with("--optimize")));
860    }
861
862    #[test]
863    fn forwarded_build_args_relativizes_manifest_path() {
864        let cmd = Cmd {
865            manifest_path: Some(PathBuf::from("/tmp/ws/contracts/add/Cargo.toml")),
866            ..Cmd::default()
867        };
868        let args = forwarded_build_args(&cmd, &ws(), None, true, true, true);
869        assert!(args.contains(&"--manifest-path=contracts/add/Cargo.toml".to_string()));
870    }
871
872    #[test]
873    fn compose_shell_command_chains_and_escapes() {
874        let a = vec![
875            "contract".to_string(),
876            "build".to_string(),
877            "--package=another".to_string(),
878            "--meta=note=added on build".to_string(),
879        ];
880        let b = vec![
881            "contract".to_string(),
882            "build".to_string(),
883            "--package=hello-world".to_string(),
884        ];
885        let s = compose_shell_command("stellar", &[a.clone(), b.clone()]);
886        assert!(s.contains("stellar contract build --package=another"));
887        assert!(s.contains("&&"));
888        assert!(s.contains("stellar contract build --package=hello-world"));
889        // A value with a space must be quoted so it stays one token.
890        assert!(
891            s.contains("'--meta=note=added on build'")
892                || s.contains("\"--meta=note=added on build\""),
893            "expected the spaced token to be quoted, got: {s}"
894        );
895
896        // An older image's binary (`soroban`) is used verbatim in the chain.
897        let s = compose_shell_command("soroban", &[a, b]);
898        assert!(s.contains("soroban contract build --package=another"));
899        assert!(s.contains("soroban contract build --package=hello-world"));
900        assert!(!s.contains("stellar"));
901    }
902
903    #[test]
904    fn compose_invocation_single_package_uses_default_entrypoint() {
905        let cmds = vec![vec![
906            "contract".to_string(),
907            "build".to_string(),
908            "--meta=field=value".to_string(),
909            "--optimize".to_string(),
910        ]];
911        let (entrypoint, post_image, reproduce) = compose_invocation(
912            "docker",
913            "",
914            "/ws:/source",
915            " -e CARGO_TARGET_DIR=/source/target",
916            "docker.io/stellar/stellar-cli:latest",
917            "stellar",
918            &cmds,
919        );
920        assert!(entrypoint.is_none());
921        assert_eq!(post_image, cmds[0]);
922        // The bind mount and image ref are shell-escaped (single-quoted here
923        // because of the `:`), so the line copy-pastes back to the same argv.
924        assert_eq!(
925            reproduce,
926            "docker run --rm -v '/ws:/source' -w /source \
927             -e CARGO_TARGET_DIR=/source/target \
928             'docker.io/stellar/stellar-cli:latest' \
929             contract build --meta=field=value --optimize"
930        );
931    }
932
933    #[test]
934    fn compose_invocation_escapes_spaced_bind_and_image() {
935        // A workspace path with a space must stay one token in the copy-pasteable
936        // reproduce line, as must a metacharacter-laden image ref.
937        let cmds = vec![vec!["contract".to_string(), "build".to_string()]];
938        let (_entrypoint, _post_image, reproduce) = compose_invocation(
939            "docker",
940            "",
941            "/Users/me/My Project/ws:/source",
942            "",
943            "my registry/img:tag",
944            "stellar",
945            &cmds,
946        );
947        // The whole `-v` value and the image ref round-trip as single tokens.
948        let tokens = shlex::split(&reproduce).expect("reproduce line must be valid shell");
949        assert!(tokens.contains(&"/Users/me/My Project/ws:/source".to_string()));
950        assert!(tokens.contains(&"my registry/img:tag".to_string()));
951    }
952
953    #[test]
954    fn compose_invocation_includes_engine_prefix_verbatim() {
955        // The prefix (which may carry `-H <host>`) is rendered before `run`, so a
956        // copy-paste hits the same daemon the CLI used.
957        let cmds = vec![vec!["contract".to_string(), "build".to_string()]];
958        let (_entrypoint, _post_image, reproduce) = compose_invocation(
959            "docker -H ssh://host",
960            "",
961            "/ws:/source",
962            "",
963            "img:tag",
964            "stellar",
965            &cmds,
966        );
967        assert!(
968            reproduce.starts_with(
969                "docker -H ssh://host run --rm -v '/ws:/source' -w /source 'img:tag' contract build"
970            ),
971            "got: {reproduce}"
972        );
973    }
974
975    #[test]
976    fn compose_invocation_multi_package_chains_through_shell() {
977        let cmds = vec![
978            vec![
979                "contract".to_string(),
980                "build".to_string(),
981                "--package=a".to_string(),
982            ],
983            vec![
984                "contract".to_string(),
985                "build".to_string(),
986                "--package=b".to_string(),
987            ],
988        ];
989        let (entrypoint, post_image, reproduce) = compose_invocation(
990            "container",
991            " --cpus 2",
992            "/ws:/source",
993            "",
994            "img:tag",
995            "stellar",
996            &cmds,
997        );
998        assert_eq!(entrypoint, Some("/bin/sh"));
999        assert_eq!(post_image[0], "-c");
1000        assert_eq!(
1001            post_image[1],
1002            "stellar contract build --package=a && stellar contract build --package=b"
1003        );
1004        assert!(reproduce
1005            .starts_with("container run --rm --cpus 2 -v '/ws:/source' -w /source --entrypoint /bin/sh 'img:tag' -c "));
1006        // The chained script is passed as one shell-quoted argument.
1007        assert!(reproduce.contains(
1008            "'stellar contract build --package=a && stellar contract build --package=b'"
1009        ));
1010
1011        // A pre-21.0.0 image's `soroban` binary flows through to the chain.
1012        let (_entrypoint, post_image, reproduce) = compose_invocation(
1013            "container",
1014            "",
1015            "/ws:/source",
1016            "",
1017            "img:tag",
1018            "soroban",
1019            &cmds,
1020        );
1021        assert_eq!(
1022            post_image[1],
1023            "soroban contract build --package=a && soroban contract build --package=b"
1024        );
1025        assert!(reproduce.contains(
1026            "'soroban contract build --package=a && soroban contract build --package=b'"
1027        ));
1028    }
1029
1030    #[test]
1031    fn parse_cli_version_reads_first_line_only() {
1032        // Old `soroban` binary: must take 0.1.2, not the `23` on the next line.
1033        assert_eq!(
1034            parse_cli_version(
1035                "soroban-cli 0.1.2 (70110a1eb3e3af0bee4ac93d005eb2614e9c8e85)\n\
1036                 soroban-env-interface-version: 23\n"
1037            ),
1038            Some(Version::parse("0.1.2").unwrap())
1039        );
1040        // Current `stellar` binary: must take 27.1.0, not the stellar-xdr 22.1.0.
1041        assert_eq!(
1042            parse_cli_version(
1043                "stellar 27.1.0 (abc123)\n\
1044                 stellar-xdr 22.1.0 (def456)\n\
1045                 xdr curr (ghi789)\n"
1046            ),
1047            Some(Version::parse("27.1.0").unwrap())
1048        );
1049        // No trailing git hash.
1050        assert_eq!(
1051            parse_cli_version("stellar 26.1.0\n"),
1052            Some(Version::parse("26.1.0").unwrap())
1053        );
1054        assert_eq!(parse_cli_version(""), None);
1055        assert_eq!(parse_cli_version("not a version\n"), None);
1056    }
1057
1058    #[test]
1059    fn probe_value_splits_tagged_combined_output() {
1060        let stdout = "BIN:stellar\n\
1061                      VERSION:stellar 27.1.0 (abc123)\n\
1062                      TOOLCHAIN:1.97.1-aarch64-unknown-linux-gnu (default)\n";
1063        assert_eq!(probe_value(stdout, "BIN:"), "stellar");
1064        assert_eq!(probe_value(stdout, "VERSION:"), "stellar 27.1.0 (abc123)");
1065        assert_eq!(
1066            probe_value(stdout, "TOOLCHAIN:"),
1067            "1.97.1-aarch64-unknown-linux-gnu (default)"
1068        );
1069        // The tagged values feed the same parsers used on standalone output.
1070        assert_eq!(
1071            parse_cli_version(probe_value(stdout, "VERSION:")),
1072            Some(Version::parse("27.1.0").unwrap())
1073        );
1074        assert_eq!(
1075            parse_default_toolchain(probe_value(stdout, "TOOLCHAIN:")).as_deref(),
1076            Some("1.97.1-aarch64-unknown-linux-gnu")
1077        );
1078        // A missing tag (fact not gathered) yields an empty value.
1079        assert_eq!(probe_value("BIN:soroban\n", "TOOLCHAIN:"), "");
1080    }
1081
1082    #[test]
1083    fn parse_default_toolchain_extracts_name() {
1084        assert_eq!(
1085            parse_default_toolchain("1.97.1-aarch64-unknown-linux-gnu (default)\n").as_deref(),
1086            Some("1.97.1-aarch64-unknown-linux-gnu")
1087        );
1088        assert_eq!(
1089            parse_default_toolchain("stable-x86_64-unknown-linux-gnu (default)").as_deref(),
1090            Some("stable-x86_64-unknown-linux-gnu")
1091        );
1092        assert_eq!(parse_default_toolchain("").as_deref(), None);
1093        assert_eq!(parse_default_toolchain("   \n").as_deref(), None);
1094    }
1095
1096    #[test]
1097    fn newest_existing_artifact_prefers_freshest_not_first() {
1098        use std::time::{Duration, SystemTime};
1099        let dir = tempfile::tempdir().unwrap();
1100        let old = dir.path().join("old.wasm");
1101        let new = dir.path().join("new.wasm");
1102        std::fs::write(&old, b"old").unwrap();
1103        std::fs::write(&new, b"new").unwrap();
1104        // Pin mtimes so the ordering is unambiguous regardless of filesystem
1105        // timestamp resolution.
1106        let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
1107        std::fs::OpenOptions::new()
1108            .write(true)
1109            .open(&old)
1110            .unwrap()
1111            .set_modified(base)
1112            .unwrap();
1113        std::fs::OpenOptions::new()
1114            .write(true)
1115            .open(&new)
1116            .unwrap()
1117            .set_modified(base + Duration::from_mins(1))
1118            .unwrap();
1119
1120        // `old` is listed first, but the fresher `new` must win — selection is by
1121        // mtime, not list position (the staleness bug this guards against).
1122        assert_eq!(
1123            newest_existing_artifact(&[old.clone(), new.clone()]),
1124            Some(new)
1125        );
1126        // A non-existent candidate is skipped; the one real file is returned.
1127        let missing = dir.path().join("missing.wasm");
1128        assert_eq!(
1129            newest_existing_artifact(&[missing.clone(), old.clone()]),
1130            Some(old)
1131        );
1132        // Nothing exists → None (caller falls back to the host-default path).
1133        assert_eq!(newest_existing_artifact(&[missing]), None);
1134    }
1135
1136    #[test]
1137    fn escape_args_round_trips_spaced_tokens() {
1138        let cmd = vec![
1139            "contract".to_string(),
1140            "build".to_string(),
1141            "--meta=note=added on build".to_string(),
1142        ];
1143        let s = escape_args(&cmd);
1144        let tokens = shlex::split(&s).expect("reproduce args must be valid shell");
1145        assert_eq!(
1146            tokens,
1147            vec!["contract", "build", "--meta=note=added on build"]
1148        );
1149    }
1150}