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 std::{
8    borrow::Cow,
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/// Build a contract from source
29///
30/// Builds all crates that are referenced by the cargo manifest (Cargo.toml)
31/// that have cdylib as their crate-type. Crates are built for the wasm32
32/// target. Unless configured otherwise, crates are built with their default
33/// features and with their release profile.
34///
35/// In workspaces builds all crates unless a package name is specified, or the
36/// command is executed from the sub-directory of a workspace crate.
37///
38/// To view the commands that will be executed, without executing them, use the
39/// --print-commands-only option.
40#[derive(Parser, Debug, Clone)]
41#[allow(clippy::struct_excessive_bools)]
42pub struct Cmd {
43    /// Path to Cargo.toml
44    #[arg(long)]
45    pub manifest_path: Option<std::path::PathBuf>,
46    /// Package to build
47    ///
48    /// If omitted, all packages that build for crate-type cdylib are built.
49    #[arg(long)]
50    pub package: Option<String>,
51
52    /// Build with the specified profile
53    #[arg(long, default_value = "release")]
54    pub profile: String,
55
56    /// Build with the list of features activated, space or comma separated
57    #[arg(long, help_heading = "Features")]
58    pub features: Option<String>,
59
60    /// Build with the all features activated
61    #[arg(
62        long,
63        conflicts_with = "features",
64        conflicts_with = "no_default_features",
65        help_heading = "Features"
66    )]
67    pub all_features: bool,
68
69    /// Build with the default feature not activated
70    #[arg(long, help_heading = "Features")]
71    pub no_default_features: bool,
72
73    /// Directory to copy wasm files to
74    ///
75    /// If provided, wasm files can be found in the cargo target directory, and
76    /// the specified directory.
77    ///
78    /// If ommitted, wasm files are written only to the cargo target directory.
79    #[arg(long)]
80    pub out_dir: Option<std::path::PathBuf>,
81
82    /// Print commands to build without executing them
83    #[arg(long, conflicts_with = "out_dir", help_heading = "Other")]
84    pub print_commands_only: bool,
85
86    /// Add key-value to contract meta (adds the meta to the `contractmetav0` custom section)
87    #[arg(long, num_args=1, value_parser=parse_meta_arg, action=clap::ArgAction::Append, help_heading = "Metadata")]
88    pub meta: Vec<(String, String)>,
89
90    /// Optimize the generated wasm.
91    #[cfg_attr(feature = "additional-libs", arg(long))]
92    #[cfg_attr(not(feature = "additional-libs"), arg(long, hide = true))]
93    pub optimize: bool,
94}
95
96fn parse_meta_arg(s: &str) -> Result<(String, String), Error> {
97    let parts = s.splitn(2, '=');
98
99    let (key, value) = parts
100        .map(str::trim)
101        .next_tuple()
102        .ok_or_else(|| Error::MetaArg("must be in the form 'key=value'".to_string()))?;
103
104    Ok((key.to_string(), value.to_string()))
105}
106
107#[derive(thiserror::Error, Debug)]
108pub enum Error {
109    #[error(transparent)]
110    Metadata(#[from] cargo_metadata::Error),
111
112    #[error(transparent)]
113    CargoCmd(io::Error),
114
115    #[error("exit status {0}")]
116    Exit(ExitStatus),
117
118    #[error("package {package} not found")]
119    PackageNotFound { package: String },
120
121    #[error("finding absolute path of Cargo.toml: {0}")]
122    AbsolutePath(io::Error),
123
124    #[error("creating out directory: {0}")]
125    CreatingOutDir(io::Error),
126
127    #[error("deleting existing artifact: {0}")]
128    DeletingArtifact(io::Error),
129
130    #[error("copying wasm file: {0}")]
131    CopyingWasmFile(io::Error),
132
133    #[error("getting the current directory: {0}")]
134    GettingCurrentDir(io::Error),
135
136    #[error("retreiving CARGO_HOME: {0}")]
137    CargoHome(io::Error),
138
139    #[error("reading wasm file: {0}")]
140    ReadingWasmFile(io::Error),
141
142    #[error("writing wasm file: {0}")]
143    WritingWasmFile(io::Error),
144
145    #[error("invalid meta entry: {0}")]
146    MetaArg(String),
147
148    #[error(
149        "use a rust version other than 1.81, 1.82, 1.83 or 1.91.0 to build contracts (got {0})"
150    )]
151    RustVersion(String),
152
153    #[error("must install with \"additional-libs\" feature.")]
154    OptimizeFeatureNotEnabled,
155
156    #[error(transparent)]
157    Xdr(#[from] stellar_xdr::curr::Error),
158
159    #[cfg(feature = "additional-libs")]
160    #[error(transparent)]
161    Optimize(#[from] optimize::Error),
162
163    #[error(transparent)]
164    Wasm(#[from] wasm::Error),
165}
166
167const WASM_TARGET: &str = "wasm32v1-none";
168const WASM_TARGET_OLD: &str = "wasm32-unknown-unknown";
169const META_CUSTOM_SECTION_NAME: &str = "contractmetav0";
170
171impl Cmd {
172    #[allow(clippy::too_many_lines)]
173    pub fn run(&self, global_args: &global::Args) -> Result<(), Error> {
174        let print = Print::new(global_args.quiet);
175        let working_dir = env::current_dir().map_err(Error::GettingCurrentDir)?;
176        let metadata = self.metadata()?;
177        let packages = self.packages(&metadata)?;
178        let target_dir = &metadata.target_directory;
179
180        // Run build configuration checks (only when actually building)
181        if !self.print_commands_only {
182            run_checks(&print, metadata.workspace_root.as_std_path(), &self.profile);
183        }
184
185        if let Some(package) = &self.package {
186            if packages.is_empty() {
187                return Err(Error::PackageNotFound {
188                    package: package.clone(),
189                });
190            }
191        }
192
193        let wasm_target = get_wasm_target()?;
194
195        for p in packages {
196            let mut cmd = Command::new("cargo");
197            cmd.stdout(Stdio::piped());
198            cmd.arg("rustc");
199            let manifest_path = pathdiff::diff_paths(&p.manifest_path, &working_dir)
200                .unwrap_or(p.manifest_path.clone().into());
201            cmd.arg(format!(
202                "--manifest-path={}",
203                manifest_path.to_string_lossy()
204            ));
205            cmd.arg("--crate-type=cdylib");
206            cmd.arg(format!("--target={wasm_target}"));
207            if self.profile == "release" {
208                cmd.arg("--release");
209            } else {
210                cmd.arg(format!("--profile={}", self.profile));
211            }
212            if self.all_features {
213                cmd.arg("--all-features");
214            }
215            if self.no_default_features {
216                cmd.arg("--no-default-features");
217            }
218            if let Some(features) = self.features() {
219                let requested: HashSet<String> = features.iter().cloned().collect();
220                let available = p.features.iter().map(|f| f.0).cloned().collect();
221                let activate = requested.intersection(&available).join(",");
222                if !activate.is_empty() {
223                    cmd.arg(format!("--features={activate}"));
224                }
225            }
226
227            if let Some(rustflags) = make_rustflags_to_remap_absolute_paths(&print)? {
228                cmd.env("CARGO_BUILD_RUSTFLAGS", rustflags);
229            }
230
231            let mut cmd_str_parts = Vec::<String>::new();
232            cmd_str_parts.extend(cmd.get_envs().map(|(key, val)| {
233                format!(
234                    "{}={}",
235                    key.to_string_lossy(),
236                    shell_escape::escape(val.unwrap_or_default().to_string_lossy())
237                )
238            }));
239            cmd_str_parts.push("cargo".to_string());
240            cmd_str_parts.extend(
241                cmd.get_args()
242                    .map(OsStr::to_string_lossy)
243                    .map(Cow::into_owned),
244            );
245            let cmd_str = cmd_str_parts.join(" ");
246
247            if self.print_commands_only {
248                println!("{cmd_str}");
249            } else {
250                print.infoln(cmd_str);
251                let status = cmd.status().map_err(Error::CargoCmd)?;
252                if !status.success() {
253                    return Err(Error::Exit(status));
254                }
255
256                let wasm_name = p.name.replace('-', "_");
257                let file = format!("{wasm_name}.wasm");
258                let target_file_path = Path::new(target_dir)
259                    .join(&wasm_target)
260                    .join(&self.profile)
261                    .join(&file);
262
263                self.inject_meta(&target_file_path)?;
264
265                let final_path = if let Some(out_dir) = &self.out_dir {
266                    fs::create_dir_all(out_dir).map_err(Error::CreatingOutDir)?;
267                    let out_file_path = Path::new(out_dir).join(&file);
268                    fs::copy(target_file_path, &out_file_path).map_err(Error::CopyingWasmFile)?;
269                    out_file_path
270                } else {
271                    target_file_path
272                };
273
274                let wasm_bytes = fs::read(&final_path).map_err(Error::ReadingWasmFile)?;
275                #[cfg_attr(not(feature = "additional-libs"), allow(unused_mut))]
276                let mut optimized_wasm_bytes: Vec<u8> = Vec::new();
277
278                #[cfg(feature = "additional-libs")]
279                if self.optimize {
280                    let mut path = final_path.clone();
281                    path.set_extension("optimized.wasm");
282                    optimize::optimize(true, vec![final_path.clone()], Some(path.clone()))?;
283                    optimized_wasm_bytes = fs::read(&path).map_err(Error::ReadingWasmFile)?;
284
285                    fs::remove_file(&final_path).map_err(Error::DeletingArtifact)?;
286                    fs::rename(&path, &final_path).map_err(Error::CopyingWasmFile)?;
287                }
288
289                #[cfg(not(feature = "additional-libs"))]
290                if self.optimize {
291                    return Err(Error::OptimizeFeatureNotEnabled);
292                }
293
294                Self::print_build_summary(&print, &final_path, wasm_bytes, optimized_wasm_bytes);
295            }
296        }
297
298        Ok(())
299    }
300
301    fn features(&self) -> Option<Vec<String>> {
302        self.features
303            .as_ref()
304            .map(|f| f.split(&[',', ' ']).map(String::from).collect())
305    }
306
307    fn packages(&self, metadata: &Metadata) -> Result<Vec<Package>, Error> {
308        // Filter by the package name if one is provided, or by the package that
309        // matches the manifest path if the manifest path matches a specific
310        // package.
311        let name = if let Some(name) = self.package.clone() {
312            Some(name)
313        } else {
314            // When matching a package based on the manifest path, match against the
315            // absolute path because the paths in the metadata are absolute. Match
316            // against a manifest in the current working directory if no manifest is
317            // specified.
318            let manifest_path = path::absolute(
319                self.manifest_path
320                    .clone()
321                    .unwrap_or(PathBuf::from("Cargo.toml")),
322            )
323            .map_err(Error::AbsolutePath)?;
324            metadata
325                .packages
326                .iter()
327                .find(|p| p.manifest_path == manifest_path)
328                .map(|p| p.name.clone())
329        };
330
331        let packages = metadata
332            .packages
333            .iter()
334            .filter(|p|
335                // Filter by the package name if one is selected based on the above logic.
336                if let Some(name) = &name {
337                    &p.name == name
338                } else {
339                    // Otherwise filter crates that are default members of the
340                    // workspace and that build to cdylib (wasm).
341                    metadata.workspace_default_members.contains(&p.id)
342                        && p.targets
343                            .iter()
344                            .any(|t| t.crate_types.iter().any(|c| c == "cdylib"))
345                }
346            )
347            .cloned()
348            .collect();
349
350        Ok(packages)
351    }
352
353    fn metadata(&self) -> Result<Metadata, cargo_metadata::Error> {
354        let mut cmd = MetadataCommand::new();
355        cmd.no_deps();
356        // Set the manifest path if one is provided, otherwise rely on the cargo
357        // commands default behavior of finding the nearest Cargo.toml in the
358        // current directory, or the parent directories above it.
359        if let Some(manifest_path) = &self.manifest_path {
360            cmd.manifest_path(manifest_path);
361        }
362        // Do not configure features on the metadata command, because we are
363        // only collecting non-dependency metadata, features have no impact on
364        // the output.
365        cmd.exec()
366    }
367
368    fn inject_meta(&self, target_file_path: &PathBuf) -> Result<(), Error> {
369        let mut wasm_bytes = fs::read(target_file_path).map_err(Error::ReadingWasmFile)?;
370        let xdr = self.encoded_new_meta()?;
371        wasm_gen::write_custom_section(&mut wasm_bytes, META_CUSTOM_SECTION_NAME, &xdr);
372
373        // Deleting .wasm file effectively unlinking it from /release/deps/.wasm preventing from overwrite
374        // See https://github.com/stellar/stellar-cli/issues/1694#issuecomment-2709342205
375        fs::remove_file(target_file_path).map_err(Error::DeletingArtifact)?;
376        fs::write(target_file_path, wasm_bytes).map_err(Error::WritingWasmFile)
377    }
378
379    fn encoded_new_meta(&self) -> Result<Vec<u8>, Error> {
380        let mut new_meta: Vec<ScMetaEntry> = Vec::new();
381
382        // Always inject CLI version
383        let cli_meta_entry = ScMetaEntry::ScMetaV0(ScMetaV0 {
384            key: "cliver".to_string().try_into().unwrap(),
385            val: version::one_line().clone().try_into().unwrap(),
386        });
387        new_meta.push(cli_meta_entry);
388
389        // Add args provided meta
390        for (k, v) in self.meta.clone() {
391            let key: StringM = k
392                .clone()
393                .try_into()
394                .map_err(|e| Error::MetaArg(format!("{k} is an invalid metadata key: {e}")))?;
395
396            let val: StringM = v
397                .clone()
398                .try_into()
399                .map_err(|e| Error::MetaArg(format!("{v} is an invalid metadata value: {e}")))?;
400            let meta_entry = ScMetaEntry::ScMetaV0(ScMetaV0 { key, val });
401            new_meta.push(meta_entry);
402        }
403
404        let mut buffer = Vec::new();
405        let mut writer = Limited::new(Cursor::new(&mut buffer), Limits::none());
406        for entry in new_meta {
407            entry.write_xdr(&mut writer)?;
408        }
409        Ok(buffer)
410    }
411
412    fn print_build_summary(
413        print: &Print,
414        path: &Path,
415        wasm_bytes: Vec<u8>,
416        optimized_wasm_bytes: Vec<u8>,
417    ) {
418        print.infoln("Build Summary:");
419
420        let rel_path = path
421            .strip_prefix(env::current_dir().unwrap())
422            .unwrap_or(path);
423
424        let size = wasm_bytes.len();
425        let optimized_size = optimized_wasm_bytes.len();
426
427        let size_description = if optimized_size > 0 {
428            format!("{optimized_size} bytes optimized (original size was {size} bytes)")
429        } else {
430            format!("{size} bytes")
431        };
432
433        let bytes = if optimized_size > 0 {
434            &optimized_wasm_bytes
435        } else {
436            &wasm_bytes
437        };
438
439        print.blankln(format!(
440            "Wasm File: {path} ({size_description})",
441            path = rel_path.display()
442        ));
443
444        print.blankln(format!("Wasm Hash: {}", hex::encode(Sha256::digest(bytes))));
445        print.blankln(format!("Wasm Size: {size_description}"));
446
447        let parser = wasmparser::Parser::new(0);
448        let export_names: Vec<&str> = parser
449            .parse_all(&wasm_bytes)
450            .filter_map(Result::ok)
451            .filter_map(|payload| {
452                if let wasmparser::Payload::ExportSection(exports) = payload {
453                    Some(exports)
454                } else {
455                    None
456                }
457            })
458            .flatten()
459            .filter_map(Result::ok)
460            .filter(|export| matches!(export.kind, wasmparser::ExternalKind::Func))
461            .map(|export| export.name)
462            .sorted()
463            .collect();
464
465        if export_names.is_empty() {
466            print.blankln("Exported Functions: None found");
467        } else {
468            print.blankln(format!("Exported Functions: {} found", export_names.len()));
469            for name in export_names {
470                print.blankln(format!("  • {name}"));
471            }
472        }
473
474        print.checkln("Build Complete\n");
475    }
476}
477
478/// Configure cargo/rustc to replace absolute paths in panic messages / debuginfo
479/// with relative paths.
480///
481/// This is required for reproducible builds.
482///
483/// This works for paths to crates in the registry. The compiler already does
484/// something similar for standard library paths and local paths. It may not
485/// work for crates that come from other sources, including the standard library
486/// compiled from source, though it may be possible to accomodate such cases in
487/// the future.
488///
489/// This in theory breaks the ability of debuggers to find source code, but
490/// since we are only targetting wasm, which is not typically run in a debugger,
491/// and stellar-cli only compiles contracts in release mode, the impact is on
492/// debugging is expected to be minimal.
493///
494/// This works by setting the `CARGO_BUILD_RUSTFLAGS` environment variable,
495/// with appropriate `--remap-path-prefix` option. It preserves the values of an
496/// existing `CARGO_BUILD_RUSTFLAGS` environment variable.
497///
498/// This must be done some via some variation of `RUSTFLAGS` and not as
499/// arguments to `cargo rustc` because the latter only applies to the crate
500/// directly being compiled, while `RUSTFLAGS` applies to all crates, including
501/// dependencies.
502///
503/// `CARGO_BUILD_RUSTFLAGS` is an alias for the `build.rustflags` configuration
504/// variable. Cargo automatically merges the contents of the environment variable
505/// and the variables from config files; and `build.rustflags` has the lowest
506/// priority of all the variations of rustflags that Cargo accepts. And because
507/// we merge our values with an existing `CARGO_BUILD_RUSTFLAGS`,
508/// our setting of this environment variable should not interfere with the
509/// user's ability to set rustflags in any way they want, but it does mean
510/// that if the user sets a higher-priority rustflags that our path remapping
511/// will be ignored.
512///
513/// The major downside of using `CARGO_BUILD_RUSTFLAGS` is that it is whitespace
514/// separated, which means we cannot support paths with spaces. If we encounter
515/// such paths we will emit a warning. Spaces could be accomodated by using
516/// `CARGO_ENCODED_RUSTFLAGS`, but that has high precedence over other rustflags,
517/// so we could be interfering with the user's own use of rustflags. There is
518/// no "encoded" variant of `CARGO_BUILD_RUSTFLAGS` at time of writing.
519///
520/// This assumes that paths are Unicode and that any existing `CARGO_BUILD_RUSTFLAGS`
521/// variables are Unicode. Non-Unicode paths will fail to correctly perform the
522/// the absolute path replacement. Non-Unicode `CARGO_BUILD_RUSTFLAGS` will result in the
523/// existing rustflags being ignored, which is also the behavior of
524/// Cargo itself.
525fn make_rustflags_to_remap_absolute_paths(print: &Print) -> Result<Option<String>, Error> {
526    let cargo_home = home::cargo_home().map_err(Error::CargoHome)?;
527
528    if format!("{}", cargo_home.display())
529        .find(|c: char| c.is_whitespace())
530        .is_some()
531    {
532        print.warnln("Cargo home directory contains whitespace. Dependency paths will not be remapped; builds may not be reproducible.");
533        return Ok(None);
534    }
535
536    if env::var("RUSTFLAGS").is_ok() {
537        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.");
538        return Ok(None);
539    }
540
541    if env::var("CARGO_ENCODED_RUSTFLAGS").is_ok() {
542        print.warnln("`CARGO_ENCODED_RUSTFLAGS` set. Dependency paths will not be remapped; builds may not be reproducible.");
543        return Ok(None);
544    }
545
546    let target = get_wasm_target()?;
547    let env_var_name = format!("TARGET_{target}_RUSTFLAGS");
548
549    if env::var(env_var_name.clone()).is_ok() {
550        print.warnln(format!("`{env_var_name}` set. Dependency paths will not be remapped; builds may not be reproducible."));
551        return Ok(None);
552    }
553
554    let registry_prefix = cargo_home.join("registry").join("src");
555    let registry_prefix_str = registry_prefix.display().to_string();
556    #[cfg(windows)]
557    let registry_prefix_str = registry_prefix_str.replace('\\', "/");
558    let new_rustflag = format!("--remap-path-prefix={registry_prefix_str}=");
559
560    let mut rustflags = get_rustflags().unwrap_or_default();
561    rustflags.push(new_rustflag);
562
563    let rustflags = rustflags.join(" ");
564
565    Ok(Some(rustflags))
566}
567
568/// Get any existing `CARGO_BUILD_RUSTFLAGS`, split on whitespace.
569///
570/// This conveniently ignores non-Unicode values, as does Cargo.
571fn get_rustflags() -> Option<Vec<String>> {
572    if let Ok(a) = env::var("CARGO_BUILD_RUSTFLAGS") {
573        let args = a
574            .split_whitespace()
575            .map(str::trim)
576            .filter(|s| !s.is_empty())
577            .map(str::to_string);
578        return Some(args.collect());
579    }
580
581    None
582}
583
584fn get_wasm_target() -> Result<String, Error> {
585    let Ok(current_version) = version() else {
586        return Ok(WASM_TARGET.into());
587    };
588
589    let v184 = Version::parse("1.84.0").unwrap();
590    let v182 = Version::parse("1.82.0").unwrap();
591    let v191 = Version::parse("1.91.0").unwrap();
592
593    if current_version == v191 {
594        return Err(Error::RustVersion(current_version.to_string()));
595    }
596
597    if current_version >= v182 && current_version < v184 {
598        return Err(Error::RustVersion(current_version.to_string()));
599    }
600
601    if current_version < v184 {
602        Ok(WASM_TARGET_OLD.into())
603    } else {
604        Ok(WASM_TARGET.into())
605    }
606}
607
608/// Run build configuration checks and emit warnings for potential issues.
609/// Each check is responsible for emitting its own warnings.
610fn run_checks(print: &Print, workspace_root: &Path, profile: &str) {
611    let cargo_toml_path = workspace_root.join("Cargo.toml");
612
613    let cargo_toml_str = match fs::read_to_string(&cargo_toml_path) {
614        Ok(s) => s,
615        Err(e) => {
616            print.warnln(format!("Could not read Cargo.toml to run checks: {e}"));
617            return;
618        }
619    };
620
621    let doc: toml_edit::DocumentMut = match cargo_toml_str.parse() {
622        Ok(d) => d,
623        Err(e) => {
624            print.warnln(format!("Could not parse Cargo.toml to run checks: {e}"));
625            return;
626        }
627    };
628
629    check_overflow_checks(print, &doc, profile);
630    // Future checks can be added here
631}
632
633/// Check if overflow-checks is enabled for the specified profile.
634/// Emits a warning if not enabled.
635fn check_overflow_checks(print: &Print, doc: &toml_edit::DocumentMut, profile: &str) {
636    // Helper to check a profile and follow inheritance chain
637    fn get_overflow_checks(
638        doc: &toml_edit::DocumentMut,
639        profile: &str,
640        visited: &mut Vec<String>,
641    ) -> Option<bool> {
642        if visited.contains(&profile.to_string()) {
643            return None; // Prevent infinite loops
644        }
645        visited.push(profile.to_string());
646
647        let profile_section = doc.get("profile")?.get(profile)?;
648
649        // Check if overflow-checks is explicitly set
650        if let Some(val) = profile_section
651            .get("overflow-checks")
652            .and_then(toml_edit::Item::as_bool)
653        {
654            return Some(val);
655        }
656
657        // Check inherited profile
658        if let Some(inherits) = profile_section.get("inherits").and_then(|v| v.as_str()) {
659            return get_overflow_checks(doc, inherits, visited);
660        }
661
662        None
663    }
664
665    let mut visited = Vec::new();
666    if get_overflow_checks(doc, profile, &mut visited) != Some(true) {
667        print.warnln(format!(
668            "`overflow-checks` is not enabled for profile `{profile}`. \
669            To prevent silent integer overflow, add `overflow-checks = true` to \
670            [profile.{profile}] in your Cargo.toml."
671        ));
672    }
673}