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