Skip to main content

soroban_cli/commands/contract/
build.rs

1use cargo_metadata::{Metadata, MetadataCommand, Package};
2use clap::Parser;
3use itertools::Itertools;
4use rustc_version::version;
5use semver::Version;
6use sha2::{Digest, Sha256};
7use soroban_spec_tools::sanitize;
8use std::{
9    collections::HashSet,
10    env,
11    ffi::OsStr,
12    fmt::Debug,
13    fs,
14    io::{self, Cursor},
15    path::{self, Path, PathBuf},
16    process::{Command, ExitStatus, Stdio},
17};
18use stellar_xdr::{Limited, Limits, ScMetaEntry, ScMetaV0, StringM, WriteXdr};
19
20#[cfg(feature = "additional-libs")]
21use crate::commands::contract::optimize;
22use crate::utils::XDR_DEPTH_LIMIT;
23use crate::{
24    commands::{
25        container::shared::{Args as ContainerArgs, RunArgs as ContainerRunArgs},
26        global, version, HEADING_CONTAINER,
27    },
28    print::Print,
29    wasm,
30};
31
32pub mod container;
33
34/// A built WASM artifact with its package name and file path.
35#[derive(Debug, Clone)]
36pub struct BuiltContract {
37    /// The Cargo package name (e.g. "my-contract").
38    pub name: String,
39    /// The path to the built WASM file.
40    pub path: PathBuf,
41}
42
43/// Build a contract from source
44///
45/// Builds all crates that are referenced by the cargo manifest (Cargo.toml)
46/// that have cdylib as their crate-type. Crates are built for the wasm32
47/// target. Unless configured otherwise, crates are built with their default
48/// features and with their release profile.
49///
50/// In workspaces builds all crates unless a package name is specified, or the
51/// command is executed from the sub-directory of a workspace crate.
52///
53/// To view the commands that will be executed, without executing them, use the
54/// --print-commands-only option.
55#[derive(Parser, Debug, Clone)]
56#[allow(clippy::struct_excessive_bools)]
57pub struct Cmd {
58    /// Path to Cargo.toml
59    #[arg(long)]
60    pub manifest_path: Option<std::path::PathBuf>,
61    /// Package to build
62    ///
63    /// If omitted, all packages that build for crate-type cdylib are built.
64    #[arg(long)]
65    pub package: Option<String>,
66
67    /// Build with the specified profile
68    #[arg(long, default_value = "release")]
69    pub profile: String,
70
71    /// Build with the list of features activated, space or comma separated
72    #[arg(long, help_heading = "Features")]
73    pub features: Option<String>,
74
75    /// Build with the all features activated
76    #[arg(
77        long,
78        conflicts_with = "features",
79        conflicts_with = "no_default_features",
80        help_heading = "Features"
81    )]
82    pub all_features: bool,
83
84    /// Build with the default feature not activated
85    #[arg(long, help_heading = "Features")]
86    pub no_default_features: bool,
87
88    /// Directory to copy wasm files to
89    ///
90    /// If provided, wasm files can be found in the cargo target directory, and
91    /// the specified directory.
92    ///
93    /// If ommitted, wasm files are written only to the cargo target directory.
94    #[arg(long)]
95    pub out_dir: Option<std::path::PathBuf>,
96
97    /// Assert that `Cargo.lock` will remain unchanged
98    #[arg(long)]
99    pub locked: bool,
100
101    /// Print commands to build without executing them
102    #[arg(long, conflicts_with = "out_dir", help_heading = "Other")]
103    pub print_commands_only: bool,
104
105    /// Build inside this container image (e.g.
106    /// `docker.io/stellar/stellar-cli:latest`). When set, the build runs in the
107    /// container against the bind-mounted working tree instead of locally. Any
108    /// tag or digest ref is accepted.
109    ///
110    /// On Linux the container runs as your uid:gid so built wasm isn't
111    /// root-owned; this assumes the image keeps CARGO_HOME/RUSTUP_HOME writable
112    /// by non-root users, as the official image does.
113    #[arg(long, help_heading = HEADING_CONTAINER)]
114    pub image: Option<String>,
115
116    /// Pull `--image` before building to refresh a moving tag.
117    ///
118    /// By default the build uses the image already present locally and doesn't
119    /// pull (matching `docker run`), so a locally-built or digest-pinned image is
120    /// used as-is. Pass `--pull` to fetch the newest image for the tag first.
121    #[arg(long, requires = "image", help_heading = HEADING_CONTAINER)]
122    pub pull: bool,
123
124    #[command(flatten)]
125    pub build_args: BuildArgs,
126
127    // Declared after `build_args` so their `next_help_heading` groups them under
128    // the Container heading without leaking it onto the ungrouped flags above.
129    /// Container connection options (`--engine`, `--docker-host`) used when
130    /// `--image` is set. `--docker-host` is honored only by the docker engine.
131    #[command(flatten, next_help_heading = HEADING_CONTAINER)]
132    pub container_args: ContainerArgs,
133
134    /// Container resource limits (`--cpus`, `--memory`) applied to the
135    /// `--image` build container.
136    #[command(flatten, next_help_heading = HEADING_CONTAINER)]
137    pub run_args: ContainerRunArgs,
138}
139
140/// Shared build options for meta and optimization, reused by deploy and upload.
141#[derive(Parser, Debug, Clone)]
142pub struct BuildArgs {
143    /// Add key-value to contract meta (adds the meta to the `contractmetav0` custom section)
144    #[arg(long, num_args=1, value_parser=parse_meta_arg, action=clap::ArgAction::Append, help_heading = "Metadata")]
145    pub meta: Vec<(String, String)>,
146
147    /// Optimize the generated wasm. Enabled by default; pass `--optimize=false` to disable. Requires the `additional-libs` feature.
148    #[arg(
149        long,
150        default_value_t = true,
151        default_missing_value = "true",
152        num_args = 0..=1,
153        action = clap::ArgAction::Set,
154    )]
155    pub optimize: bool,
156}
157
158// Manual impl so `optimize` defaults to `true`, matching the CLI default.
159// `#[derive(Default)]` would set it to `false`.
160impl Default for BuildArgs {
161    fn default() -> Self {
162        Self {
163            meta: Vec::new(),
164            optimize: true,
165        }
166    }
167}
168
169pub fn parse_meta_arg(s: &str) -> Result<(String, String), Error> {
170    let parts = s.splitn(2, '=');
171
172    let (key, value) = parts
173        .map(str::trim)
174        .next_tuple()
175        .ok_or_else(|| Error::MetaArg("must be in the form 'key=value'".to_string()))?;
176
177    Ok((key.to_string(), value.to_string()))
178}
179
180#[derive(thiserror::Error, Debug)]
181pub enum Error {
182    #[error(transparent)]
183    Metadata(#[from] cargo_metadata::Error),
184
185    #[error(transparent)]
186    CargoCmd(io::Error),
187
188    #[error("exit status {0}")]
189    Exit(ExitStatus),
190
191    #[error("package {package} not found")]
192    PackageNotFound { package: String },
193
194    #[error("finding absolute path of Cargo.toml: {0}")]
195    AbsolutePath(io::Error),
196
197    #[error("creating out directory: {0}")]
198    CreatingOutDir(io::Error),
199
200    #[error("deleting existing artifact: {0}")]
201    DeletingArtifact(io::Error),
202
203    #[error("copying wasm file: {0}")]
204    CopyingWasmFile(io::Error),
205
206    #[error("getting the current directory: {0}")]
207    GettingCurrentDir(io::Error),
208
209    #[error("retrieving CARGO_HOME: {0}")]
210    CargoHome(io::Error),
211
212    #[error("reading wasm file: {0}")]
213    ReadingWasmFile(io::Error),
214
215    #[error("writing wasm file: {0}")]
216    WritingWasmFile(io::Error),
217
218    #[error("invalid meta entry: {0}")]
219    MetaArg(String),
220
221    #[error(
222        "use a rust version other than 1.81, 1.82, 1.83 or 1.91.0 to build contracts (got {0})"
223    )]
224    RustVersion(String),
225
226    #[error("invalid Cargo.toml configuration: {0}")]
227    CargoConfiguration(String),
228
229    #[error(transparent)]
230    Xdr(#[from] stellar_xdr::Error),
231
232    #[cfg(feature = "additional-libs")]
233    #[error(transparent)]
234    Optimize(#[from] optimize::Error),
235
236    #[error(transparent)]
237    Wasm(#[from] wasm::Error),
238
239    #[error(transparent)]
240    SpecTools(#[from] soroban_spec_tools::contract::Error),
241
242    #[error("wasm parsing error: {0}")]
243    WasmParsing(String),
244
245    #[error(transparent)]
246    Container(#[from] container::Error),
247}
248
249pub(crate) const WASM_TARGET: &str = "wasm32v1-none";
250pub(crate) const WASM_TARGET_OLD: &str = "wasm32-unknown-unknown";
251const META_CUSTOM_SECTION_NAME: &str = "contractmetav0";
252
253impl Default for Cmd {
254    fn default() -> Self {
255        Self {
256            manifest_path: None,
257            package: None,
258            profile: "release".to_string(),
259            features: None,
260            all_features: false,
261            no_default_features: false,
262            out_dir: None,
263            locked: false,
264            print_commands_only: false,
265            image: None,
266            pull: false,
267            build_args: BuildArgs::default(),
268            container_args: ContainerArgs::default(),
269            run_args: ContainerRunArgs::default(),
270        }
271    }
272}
273
274impl Cmd {
275    /// Builds the project and returns the built WASM artifacts.
276    #[allow(clippy::too_many_lines)]
277    pub async fn run(&self, global_args: &global::Args) -> Result<Vec<BuiltContract>, Error> {
278        let print = Print::new(global_args.quiet);
279
280        // When an image is given, build inside that container instead of locally.
281        if self.image.is_some() {
282            return container::run(self, global_args, &print).await;
283        }
284
285        let working_dir = env::current_dir().map_err(Error::GettingCurrentDir)?;
286        let metadata = self.metadata()?;
287        let packages = self.packages(&metadata)?;
288        let target_dir = &metadata.target_directory;
289
290        // Run build configuration checks (only when actually building)
291        if !self.print_commands_only {
292            run_checks(metadata.workspace_root.as_std_path(), &self.profile)?;
293        }
294
295        if let Some(package) = &self.package {
296            if packages.is_empty() {
297                return Err(Error::PackageNotFound {
298                    package: package.clone(),
299                });
300            }
301        }
302
303        let wasm_target = get_wasm_target()?;
304        let mut built_contracts = Vec::new();
305
306        for p in packages {
307            let mut cmd = Command::new("cargo");
308            cmd.stdout(Stdio::piped());
309            cmd.arg("rustc");
310            if self.locked {
311                cmd.arg("--locked");
312            }
313            let manifest_path = pathdiff::diff_paths(&p.manifest_path, &working_dir)
314                .unwrap_or(p.manifest_path.clone().into());
315            cmd.arg(format!(
316                "--manifest-path={}",
317                manifest_path.to_string_lossy()
318            ));
319            cmd.arg("--crate-type=cdylib");
320            cmd.arg(format!("--target={wasm_target}"));
321            if self.profile == "release" {
322                cmd.arg("--release");
323            } else {
324                cmd.arg(format!("--profile={}", self.profile));
325            }
326            if self.all_features {
327                cmd.arg("--all-features");
328            }
329            if self.no_default_features {
330                cmd.arg("--no-default-features");
331            }
332            if let Some(features) = self.features() {
333                let requested: HashSet<String> = features.iter().cloned().collect();
334                let available = p.features.iter().map(|f| f.0).cloned().collect();
335                let activate = requested.intersection(&available).join(",");
336                if !activate.is_empty() {
337                    cmd.arg(format!("--features={activate}"));
338                }
339            }
340
341            if let Some(rustflags) = make_rustflags_to_remap_absolute_paths(&print)? {
342                cmd.env("CARGO_BUILD_RUSTFLAGS", rustflags);
343            }
344
345            // Set env var to inform the SDK that this CLI supports spec
346            // optimization using markers.
347            cmd.env("SOROBAN_SDK_BUILD_SYSTEM_SUPPORTS_SPEC_SHAKING_V2", "1");
348
349            let cmd_str = serialize_command(&cmd);
350
351            if self.print_commands_only {
352                println!("{cmd_str}");
353            } else {
354                print.infoln(cmd_str);
355                let status = cmd.status().map_err(Error::CargoCmd)?;
356                if !status.success() {
357                    return Err(Error::Exit(status));
358                }
359
360                let wasm_name = p.name.replace('-', "_");
361                let file = format!("{wasm_name}.wasm");
362                let target_file_path = Path::new(target_dir)
363                    .join(&wasm_target)
364                    .join(&self.profile)
365                    .join(&file);
366
367                self.inject_meta(&target_file_path)?;
368                Self::filter_spec(&target_file_path)?;
369
370                let final_path = if let Some(out_dir) = &self.out_dir {
371                    fs::create_dir_all(out_dir).map_err(Error::CreatingOutDir)?;
372                    let out_file_path = Path::new(out_dir).join(&file);
373                    fs::copy(target_file_path, &out_file_path).map_err(Error::CopyingWasmFile)?;
374                    out_file_path
375                } else {
376                    target_file_path
377                };
378
379                let wasm_bytes = fs::read(&final_path).map_err(Error::ReadingWasmFile)?;
380                #[cfg_attr(not(feature = "additional-libs"), allow(unused_mut))]
381                let mut optimized_wasm_bytes: Vec<u8> = Vec::new();
382
383                #[cfg(feature = "additional-libs")]
384                if self.build_args.optimize {
385                    let mut path = final_path.clone();
386                    path.set_extension("optimized.wasm");
387                    optimize::optimize(true, vec![final_path.clone()], Some(path.clone()))?;
388                    optimized_wasm_bytes = fs::read(&path).map_err(Error::ReadingWasmFile)?;
389
390                    fs::remove_file(&final_path).map_err(Error::DeletingArtifact)?;
391                    fs::rename(&path, &final_path).map_err(Error::CopyingWasmFile)?;
392                }
393
394                #[cfg(not(feature = "additional-libs"))]
395                if self.build_args.optimize {
396                    print.warnln(
397                        "Optimization skipped: stellar-cli was installed without the `additional-libs` feature. \
398                         Reinstall with `--features additional-libs` to enable."
399                    );
400                }
401
402                Self::print_build_summary(
403                    &print,
404                    &p.name,
405                    &final_path,
406                    wasm_bytes,
407                    optimized_wasm_bytes,
408                );
409
410                built_contracts.push(BuiltContract {
411                    name: p.name.clone(),
412                    path: final_path,
413                });
414            }
415        }
416
417        Ok(built_contracts)
418    }
419
420    fn features(&self) -> Option<Vec<String>> {
421        self.features
422            .as_ref()
423            .map(|f| f.split(&[',', ' ']).map(String::from).collect())
424    }
425
426    fn packages(&self, metadata: &Metadata) -> Result<Vec<Package>, Error> {
427        // Filter by the package name if one is provided, or by the package that
428        // matches the manifest path if the manifest path matches a specific
429        // package.
430        let name = if let Some(name) = self.package.clone() {
431            Some(name)
432        } else {
433            // When matching a package based on the manifest path, match against the
434            // absolute path because the paths in the metadata are absolute. Match
435            // against a manifest in the current working directory if no manifest is
436            // specified.
437            let manifest_path = path::absolute(
438                self.manifest_path
439                    .clone()
440                    .unwrap_or(PathBuf::from("Cargo.toml")),
441            )
442            .map_err(Error::AbsolutePath)?;
443            metadata
444                .packages
445                .iter()
446                .find(|p| p.manifest_path == manifest_path)
447                .map(|p| p.name.clone())
448        };
449
450        let packages = metadata
451            .packages
452            .iter()
453            .filter(|p|
454                // Filter by the package name if one is selected based on the above logic.
455                if let Some(name) = &name {
456                    &p.name == name
457                } else {
458                    // Otherwise filter crates that are default members of the
459                    // workspace and that build to cdylib (wasm).
460                    metadata.workspace_default_members.contains(&p.id)
461                        && p.targets
462                            .iter()
463                            .any(|t| t.crate_types.iter().any(|c| c == "cdylib"))
464                }
465            )
466            .cloned()
467            .collect();
468
469        Ok(packages)
470    }
471
472    fn metadata(&self) -> Result<Metadata, cargo_metadata::Error> {
473        let mut cmd = MetadataCommand::new();
474        cmd.no_deps();
475        // Set the manifest path if one is provided, otherwise rely on the cargo
476        // commands default behavior of finding the nearest Cargo.toml in the
477        // current directory, or the parent directories above it.
478        if let Some(manifest_path) = &self.manifest_path {
479            cmd.manifest_path(manifest_path);
480        }
481        // Do not configure features on the metadata command, because we are
482        // only collecting non-dependency metadata, features have no impact on
483        // the output.
484        cmd.exec()
485    }
486
487    fn inject_meta(&self, target_file_path: &PathBuf) -> Result<(), Error> {
488        let mut wasm_bytes = fs::read(target_file_path).map_err(Error::ReadingWasmFile)?;
489        let xdr = self.encoded_new_meta()?;
490        wasm_gen::write_custom_section(&mut wasm_bytes, META_CUSTOM_SECTION_NAME, &xdr);
491
492        // Deleting .wasm file effectively unlinking it from /release/deps/.wasm preventing from overwrite
493        // See https://github.com/stellar/stellar-cli/issues/1694#issuecomment-2709342205
494        fs::remove_file(target_file_path).map_err(Error::DeletingArtifact)?;
495        fs::write(target_file_path, wasm_bytes).map_err(Error::WritingWasmFile)
496    }
497
498    /// Filters unused types and events from the contract spec.
499    ///
500    /// This removes:
501    /// - Type definitions that are not referenced by any function
502    /// - Events that don't have corresponding markers in the WASM data section
503    ///   (events that are defined but never published)
504    ///
505    /// The SDK embeds markers in the data section for types/events that are
506    /// actually used. These markers survive dead code elimination, so we can
507    /// detect which spec entries are truly needed.
508    fn filter_spec(target_file_path: &PathBuf) -> Result<(), Error> {
509        use soroban_spec_tools::contract::Spec;
510        use soroban_spec_tools::wasm::replace_custom_section;
511
512        let wasm_bytes = fs::read(target_file_path).map_err(Error::ReadingWasmFile)?;
513
514        // Parse the spec from the wasm
515        let spec = Spec::new(&wasm_bytes)?;
516
517        // Check if the contract meta indicates spec shaking v2 is enabled.
518        if soroban_spec::shaking::spec_shaking_version_for_meta(&spec.meta) != 2 {
519            return Ok(());
520        }
521
522        // Extract markers from the WASM data section
523        let markers = soroban_spec::shaking::find_all(&wasm_bytes);
524
525        // Filter spec entries (types, events) based on markers, and
526        // deduplicate any exact duplicate entries.
527        let filtered_xdr = filter_and_dedup_spec(spec.spec.clone(), &markers)?;
528
529        // Replace the contractspecv0 section with the filtered version
530        let new_wasm = replace_custom_section(&wasm_bytes, "contractspecv0", &filtered_xdr)
531            .map_err(|e| Error::WasmParsing(e.to_string()))?;
532
533        // Write the modified wasm back
534        fs::remove_file(target_file_path).map_err(Error::DeletingArtifact)?;
535        fs::write(target_file_path, new_wasm).map_err(Error::WritingWasmFile)
536    }
537
538    fn encoded_new_meta(&self) -> Result<Vec<u8>, Error> {
539        let mut new_meta: Vec<ScMetaEntry> = Vec::new();
540
541        // Always inject CLI version
542        let cli_meta_entry = ScMetaEntry::ScMetaV0(ScMetaV0 {
543            key: "cliver".to_string().try_into().unwrap(),
544            val: version::one_line().clone().try_into().unwrap(),
545        });
546        new_meta.push(cli_meta_entry);
547
548        // Add args provided meta
549        for (k, v) in self.build_args.meta.clone() {
550            let key: StringM = k
551                .clone()
552                .try_into()
553                .map_err(|e| Error::MetaArg(format!("{k} is an invalid metadata key: {e}")))?;
554
555            let val: StringM = v
556                .clone()
557                .try_into()
558                .map_err(|e| Error::MetaArg(format!("{v} is an invalid metadata value: {e}")))?;
559            let meta_entry = ScMetaEntry::ScMetaV0(ScMetaV0 { key, val });
560            new_meta.push(meta_entry);
561        }
562
563        let mut buffer = Vec::new();
564        let mut writer = Limited::new(Cursor::new(&mut buffer), Limits::depth(XDR_DEPTH_LIMIT));
565        for entry in new_meta {
566            entry.write_xdr(&mut writer)?;
567        }
568        Ok(buffer)
569    }
570
571    fn print_build_summary(
572        print: &Print,
573        name: &str,
574        path: &Path,
575        wasm_bytes: Vec<u8>,
576        optimized_wasm_bytes: Vec<u8>,
577    ) {
578        print.infoln("Build Summary:");
579
580        let rel_path = path
581            .strip_prefix(env::current_dir().unwrap())
582            .unwrap_or(path);
583
584        let size = wasm_bytes.len();
585        let optimized_size = optimized_wasm_bytes.len();
586
587        let size_description = if optimized_size > 0 {
588            format!("{optimized_size} bytes optimized (original size was {size} bytes)")
589        } else {
590            format!("{size} bytes")
591        };
592
593        let bytes = if optimized_size > 0 {
594            &optimized_wasm_bytes
595        } else {
596            &wasm_bytes
597        };
598
599        print.blankln(format!(
600            "Wasm File: {path} ({size_description})",
601            path = rel_path.display()
602        ));
603
604        print.blankln(format!("Wasm Hash: {}", hex::encode(Sha256::digest(bytes))));
605        print.blankln(format!("Wasm Size: {size_description}"));
606
607        let parser = wasmparser::Parser::new(0);
608        let export_names: Vec<&str> = parser
609            .parse_all(&wasm_bytes)
610            .filter_map(Result::ok)
611            .filter_map(|payload| {
612                if let wasmparser::Payload::ExportSection(exports) = payload {
613                    Some(exports)
614                } else {
615                    None
616                }
617            })
618            .flatten()
619            .filter_map(Result::ok)
620            .filter(|export| matches!(export.kind, wasmparser::ExternalKind::Func))
621            .map(|export| export.name)
622            .sorted()
623            .collect();
624
625        if export_names.is_empty() {
626            print.blankln("Exported Functions: None found");
627        } else {
628            print.blankln(format!("Exported Functions: {} found", export_names.len()));
629            for name in export_names {
630                print.blankln(format!("  • {}", sanitize(name)));
631            }
632        }
633
634        if let Ok(spec) = soroban_spec_tools::Spec::from_wasm(bytes) {
635            for w in spec.verify() {
636                print.warnln(format!("{name}: {w}"));
637            }
638        }
639
640        print.checkln("Build Complete\n");
641    }
642}
643
644fn serialize_command(cmd: &Command) -> String {
645    let mut parts = Vec::<String>::new();
646    parts.extend(cmd.get_envs().map(|(key, val)| {
647        format!(
648            "{}={}",
649            key.to_string_lossy(),
650            shell_escape::escape(val.unwrap_or_default().to_string_lossy())
651        )
652    }));
653    parts.push(cmd.get_program().to_string_lossy().into_owned());
654    parts.extend(
655        cmd.get_args()
656            .map(OsStr::to_string_lossy)
657            .map(|a| shell_escape::escape(a).into_owned()),
658    );
659    parts.join(" ")
660}
661
662/// Configure cargo/rustc to replace absolute paths in panic messages / debuginfo
663/// with relative paths.
664///
665/// This is required for reproducible builds.
666///
667/// This works for paths to crates in the registry. The compiler already does
668/// something similar for standard library paths and local paths. It may not
669/// work for crates that come from other sources, including the standard library
670/// compiled from source, though it may be possible to accomodate such cases in
671/// the future.
672///
673/// This in theory breaks the ability of debuggers to find source code, but
674/// since we are only targetting wasm, which is not typically run in a debugger,
675/// and stellar-cli only compiles contracts in release mode, the impact is on
676/// debugging is expected to be minimal.
677///
678/// This works by setting the `CARGO_BUILD_RUSTFLAGS` environment variable,
679/// with appropriate `--remap-path-prefix` option. It preserves the values of an
680/// existing `CARGO_BUILD_RUSTFLAGS` environment variable.
681///
682/// This must be done some via some variation of `RUSTFLAGS` and not as
683/// arguments to `cargo rustc` because the latter only applies to the crate
684/// directly being compiled, while `RUSTFLAGS` applies to all crates, including
685/// dependencies.
686///
687/// `CARGO_BUILD_RUSTFLAGS` is an alias for the `build.rustflags` configuration
688/// variable. Cargo automatically merges the contents of the environment variable
689/// and the variables from config files; and `build.rustflags` has the lowest
690/// priority of all the variations of rustflags that Cargo accepts. And because
691/// we merge our values with an existing `CARGO_BUILD_RUSTFLAGS`,
692/// our setting of this environment variable should not interfere with the
693/// user's ability to set rustflags in any way they want, but it does mean
694/// that if the user sets a higher-priority rustflags that our path remapping
695/// will be ignored.
696///
697/// The major downside of using `CARGO_BUILD_RUSTFLAGS` is that it is whitespace
698/// separated, which means we cannot support paths with spaces. If we encounter
699/// such paths we will emit a warning. Spaces could be accomodated by using
700/// `CARGO_ENCODED_RUSTFLAGS`, but that has high precedence over other rustflags,
701/// so we could be interfering with the user's own use of rustflags. There is
702/// no "encoded" variant of `CARGO_BUILD_RUSTFLAGS` at time of writing.
703///
704/// This assumes that paths are Unicode and that any existing `CARGO_BUILD_RUSTFLAGS`
705/// variables are Unicode. Non-Unicode paths will fail to correctly perform the
706/// the absolute path replacement. Non-Unicode `CARGO_BUILD_RUSTFLAGS` will result in the
707/// existing rustflags being ignored, which is also the behavior of
708/// Cargo itself.
709fn make_rustflags_to_remap_absolute_paths(print: &Print) -> Result<Option<String>, Error> {
710    let cargo_home = home::cargo_home().map_err(Error::CargoHome)?;
711
712    if format!("{}", cargo_home.display())
713        .find(|c: char| c.is_whitespace())
714        .is_some()
715    {
716        print.warnln("Cargo home directory contains whitespace. Dependency paths will not be remapped; builds may not be reproducible.");
717        return Ok(None);
718    }
719
720    if env::var("RUSTFLAGS").is_ok() {
721        print.warnln("`RUSTFLAGS` set. Dependency paths will not be remapped; builds may not be reproducible. Use CARGO_BUILD_RUSTFLAGS instead, which the CLI will merge with remapping.");
722        return Ok(None);
723    }
724
725    if env::var("CARGO_ENCODED_RUSTFLAGS").is_ok() {
726        print.warnln("`CARGO_ENCODED_RUSTFLAGS` set. Dependency paths will not be remapped; builds may not be reproducible.");
727        return Ok(None);
728    }
729
730    let target = get_wasm_target()?;
731    let env_var_name = format!("TARGET_{target}_RUSTFLAGS");
732
733    if env::var(env_var_name.clone()).is_ok() {
734        print.warnln(format!("`{env_var_name}` set. Dependency paths will not be remapped; builds may not be reproducible."));
735        return Ok(None);
736    }
737
738    let registry_prefix = cargo_home.join("registry").join("src");
739    let registry_prefix_str = registry_prefix.display().to_string();
740    #[cfg(windows)]
741    let registry_prefix_str = registry_prefix_str.replace('\\', "/");
742    let new_rustflag = format!("--remap-path-prefix={registry_prefix_str}=");
743
744    let mut rustflags = get_rustflags().unwrap_or_default();
745    rustflags.push(new_rustflag);
746
747    let rustflags = rustflags.join(" ");
748
749    Ok(Some(rustflags))
750}
751
752/// Get any existing `CARGO_BUILD_RUSTFLAGS`, split on whitespace.
753///
754/// This conveniently ignores non-Unicode values, as does Cargo.
755fn get_rustflags() -> Option<Vec<String>> {
756    if let Ok(a) = env::var("CARGO_BUILD_RUSTFLAGS") {
757        let args = a
758            .split_whitespace()
759            .map(str::trim)
760            .filter(|s| !s.is_empty())
761            .map(str::to_string);
762        return Some(args.collect());
763    }
764
765    None
766}
767
768pub(crate) fn get_wasm_target() -> Result<String, Error> {
769    let Ok(current_version) = version() else {
770        return Ok(WASM_TARGET.into());
771    };
772
773    let v184 = Version::parse("1.84.0").unwrap();
774    let v182 = Version::parse("1.82.0").unwrap();
775    let v191 = Version::parse("1.91.0").unwrap();
776
777    if current_version == v191 {
778        return Err(Error::RustVersion(current_version.to_string()));
779    }
780
781    if current_version >= v182 && current_version < v184 {
782        return Err(Error::RustVersion(current_version.to_string()));
783    }
784
785    if current_version < v184 {
786        Ok(WASM_TARGET_OLD.into())
787    } else {
788        Ok(WASM_TARGET.into())
789    }
790}
791
792/// Run build configuration checks and return an error if configuration is invalid.
793fn run_checks(workspace_root: &Path, profile: &str) -> Result<(), Error> {
794    let cargo_toml_path = workspace_root.join("Cargo.toml");
795
796    let cargo_toml_str = match fs::read_to_string(&cargo_toml_path) {
797        Ok(s) => s,
798        Err(e) => {
799            return Err(Error::CargoConfiguration(format!(
800                "Could not read Cargo.toml: {e}"
801            )));
802        }
803    };
804
805    let doc: toml_edit::DocumentMut = match cargo_toml_str.parse() {
806        Ok(d) => d,
807        Err(e) => {
808            return Err(Error::CargoConfiguration(format!(
809                "Could not parse Cargo.toml to run checks: {e}"
810            )));
811        }
812    };
813
814    check_overflow_checks(&doc, profile)?;
815    // Future checks can be added here
816    Ok(())
817}
818
819/// Check if overflow-checks is enabled for the specified profile.
820/// Returns an error if not enabled.
821fn check_overflow_checks(doc: &toml_edit::DocumentMut, profile: &str) -> Result<(), Error> {
822    // Helper to check a profile and follow inheritance chain
823    // Returns Some(bool) if overflow-checks is found, None if not found
824    fn get_overflow_checks(
825        doc: &toml_edit::DocumentMut,
826        profile: &str,
827        visited: &mut Vec<String>,
828    ) -> Option<bool> {
829        if visited.contains(&profile.to_string()) {
830            return None; // Prevent infinite loops
831        }
832        visited.push(profile.to_string());
833
834        let profile_section = doc.get("profile")?.get(profile)?;
835
836        // Check if overflow-checks is explicitly set
837        if let Some(val) = profile_section
838            .get("overflow-checks")
839            .and_then(toml_edit::Item::as_bool)
840        {
841            return Some(val);
842        }
843
844        // Check inherited profile
845        if let Some(inherits) = profile_section.get("inherits").and_then(|v| v.as_str()) {
846            return get_overflow_checks(doc, inherits, visited);
847        }
848
849        None
850    }
851
852    let mut visited = Vec::new();
853    if get_overflow_checks(doc, profile, &mut visited) == Some(true) {
854        Ok(())
855    } else {
856        Err(Error::CargoConfiguration(format!(
857            "`overflow-checks` is not enabled for profile `{profile}`. \
858            To prevent silent integer overflow, add `overflow-checks = true` to \
859            [profile.{profile}] in your Cargo.toml."
860        )))
861    }
862}
863
864/// Filters spec entries based on markers and deduplicates exact duplicates.
865///
866/// Functions are always kept. Other entries (types, events) are kept only if a
867/// matching marker exists. Exact duplicate entries (identical XDR) are collapsed
868/// to a single occurrence.
869#[allow(clippy::implicit_hasher)]
870pub fn filter_and_dedup_spec(
871    entries: Vec<stellar_xdr::ScSpecEntry>,
872    markers: &HashSet<soroban_spec::shaking::Marker>,
873) -> Result<Vec<u8>, Error> {
874    let mut seen = HashSet::new();
875    let mut filtered_xdr = Vec::new();
876    let mut writer = Limited::new(
877        Cursor::new(&mut filtered_xdr),
878        Limits::depth(XDR_DEPTH_LIMIT),
879    );
880    for entry in soroban_spec::shaking::filter(entries, markers) {
881        let entry_xdr = entry.to_xdr(Limits::depth(XDR_DEPTH_LIMIT))?;
882        if seen.insert(entry_xdr) {
883            entry.write_xdr(&mut writer)?;
884        }
885    }
886    Ok(filtered_xdr)
887}
888
889#[cfg(test)]
890mod tests {
891    use super::*;
892
893    #[test]
894    fn image_flag_parses_with_tag_and_container_options() {
895        let cmd = Cmd::try_parse_from([
896            "build",
897            "--image",
898            "docker.io/stellar/stellar-cli:latest",
899            "--meta",
900            "field=value",
901            "--engine",
902            "docker",
903            "--cpus",
904            "2",
905        ])
906        .expect("--image with a tag ref and container options must parse");
907        assert_eq!(
908            cmd.image.as_deref(),
909            Some("docker.io/stellar/stellar-cli:latest")
910        );
911        assert_eq!(
912            cmd.build_args.meta,
913            vec![("field".to_string(), "value".to_string())]
914        );
915        assert_eq!(cmd.run_args.cpus, Some(2));
916    }
917
918    #[test]
919    fn image_defaults_to_none() {
920        let cmd = Cmd::try_parse_from(["build"]).unwrap();
921        assert!(cmd.image.is_none());
922    }
923
924    #[test]
925    fn pull_requires_image() {
926        let cmd = Cmd::try_parse_from([
927            "build",
928            "--image",
929            "docker.io/stellar/stellar-cli:latest",
930            "--pull",
931        ])
932        .expect("--pull with --image must parse");
933        assert!(cmd.pull);
934
935        // Without --image the flag is rejected rather than silently ignored.
936        assert!(Cmd::try_parse_from(["build", "--pull"]).is_err());
937    }
938
939    #[test]
940    fn serialize_command_shell_escapes_args_with_metacharacters() {
941        let raw_arg = "--manifest-path=/path/to/contract;touch PWNED;#/Cargo.toml";
942        let escaped_arg = shell_escape::escape(raw_arg.into()).into_owned();
943
944        let mut cmd = Command::new("cargo");
945        cmd.arg("rustc");
946        cmd.arg(raw_arg);
947
948        let output = serialize_command(&cmd);
949
950        // The full escaped form of the argument must appear verbatim.
951        assert!(
952            output.contains(&escaped_arg),
953            "expected escaped arg {escaped_arg:?} in output: {output}"
954        );
955
956        // Round-trip through shlex: the metacharacter-laden arg must parse back
957        // as a single token equal to the original.
958        let tokens = shlex::split(&output).expect("serialize_command output must be valid shell");
959        assert!(
960            tokens.iter().any(|t| t == raw_arg),
961            "shlex round-trip failed: {raw_arg:?} not found as a single token in {tokens:?}"
962        );
963    }
964}