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        if let Some(package) = &self.package {
181            if packages.is_empty() {
182                return Err(Error::PackageNotFound {
183                    package: package.clone(),
184                });
185            }
186        }
187
188        let wasm_target = get_wasm_target()?;
189
190        for p in packages {
191            let mut cmd = Command::new("cargo");
192            cmd.stdout(Stdio::piped());
193            cmd.arg("rustc");
194            let manifest_path = pathdiff::diff_paths(&p.manifest_path, &working_dir)
195                .unwrap_or(p.manifest_path.clone().into());
196            cmd.arg(format!(
197                "--manifest-path={}",
198                manifest_path.to_string_lossy()
199            ));
200            cmd.arg("--crate-type=cdylib");
201            cmd.arg(format!("--target={wasm_target}"));
202            if self.profile == "release" {
203                cmd.arg("--release");
204            } else {
205                cmd.arg(format!("--profile={}", self.profile));
206            }
207            if self.all_features {
208                cmd.arg("--all-features");
209            }
210            if self.no_default_features {
211                cmd.arg("--no-default-features");
212            }
213            if let Some(features) = self.features() {
214                let requested: HashSet<String> = features.iter().cloned().collect();
215                let available = p.features.iter().map(|f| f.0).cloned().collect();
216                let activate = requested.intersection(&available).join(",");
217                if !activate.is_empty() {
218                    cmd.arg(format!("--features={activate}"));
219                }
220            }
221
222            if let Some(rustflags) = make_rustflags_to_remap_absolute_paths(&print)? {
223                cmd.env("CARGO_BUILD_RUSTFLAGS", rustflags);
224            }
225
226            let mut cmd_str_parts = Vec::<String>::new();
227            cmd_str_parts.extend(cmd.get_envs().map(|(key, val)| {
228                format!(
229                    "{}={}",
230                    key.to_string_lossy(),
231                    shell_escape::escape(val.unwrap_or_default().to_string_lossy())
232                )
233            }));
234            cmd_str_parts.push("cargo".to_string());
235            cmd_str_parts.extend(
236                cmd.get_args()
237                    .map(OsStr::to_string_lossy)
238                    .map(Cow::into_owned),
239            );
240            let cmd_str = cmd_str_parts.join(" ");
241
242            if self.print_commands_only {
243                println!("{cmd_str}");
244            } else {
245                print.infoln(cmd_str);
246                let status = cmd.status().map_err(Error::CargoCmd)?;
247                if !status.success() {
248                    return Err(Error::Exit(status));
249                }
250
251                let wasm_name = p.name.replace('-', "_");
252                let file = format!("{wasm_name}.wasm");
253                let target_file_path = Path::new(target_dir)
254                    .join(&wasm_target)
255                    .join(&self.profile)
256                    .join(&file);
257
258                self.inject_meta(&target_file_path)?;
259
260                let final_path = if let Some(out_dir) = &self.out_dir {
261                    fs::create_dir_all(out_dir).map_err(Error::CreatingOutDir)?;
262                    let out_file_path = Path::new(out_dir).join(&file);
263                    fs::copy(target_file_path, &out_file_path).map_err(Error::CopyingWasmFile)?;
264                    out_file_path
265                } else {
266                    target_file_path
267                };
268
269                let wasm_bytes = fs::read(&final_path).map_err(Error::ReadingWasmFile)?;
270                #[cfg_attr(not(feature = "additional-libs"), allow(unused_mut))]
271                let mut optimized_wasm_bytes: Vec<u8> = Vec::new();
272
273                #[cfg(feature = "additional-libs")]
274                if self.optimize {
275                    let mut path = final_path.clone();
276                    path.set_extension("optimized.wasm");
277                    optimize::optimize(true, vec![final_path.clone()], Some(path.clone()))?;
278                    optimized_wasm_bytes = fs::read(&path).map_err(Error::ReadingWasmFile)?;
279
280                    fs::remove_file(&final_path).map_err(Error::DeletingArtifact)?;
281                    fs::rename(&path, &final_path).map_err(Error::CopyingWasmFile)?;
282                }
283
284                #[cfg(not(feature = "additional-libs"))]
285                if self.optimize {
286                    return Err(Error::OptimizeFeatureNotEnabled);
287                }
288
289                Self::print_build_summary(&print, &final_path, wasm_bytes, optimized_wasm_bytes);
290            }
291        }
292
293        Ok(())
294    }
295
296    fn features(&self) -> Option<Vec<String>> {
297        self.features
298            .as_ref()
299            .map(|f| f.split(&[',', ' ']).map(String::from).collect())
300    }
301
302    fn packages(&self, metadata: &Metadata) -> Result<Vec<Package>, Error> {
303        // Filter by the package name if one is provided, or by the package that
304        // matches the manifest path if the manifest path matches a specific
305        // package.
306        let name = if let Some(name) = self.package.clone() {
307            Some(name)
308        } else {
309            // When matching a package based on the manifest path, match against the
310            // absolute path because the paths in the metadata are absolute. Match
311            // against a manifest in the current working directory if no manifest is
312            // specified.
313            let manifest_path = path::absolute(
314                self.manifest_path
315                    .clone()
316                    .unwrap_or(PathBuf::from("Cargo.toml")),
317            )
318            .map_err(Error::AbsolutePath)?;
319            metadata
320                .packages
321                .iter()
322                .find(|p| p.manifest_path == manifest_path)
323                .map(|p| p.name.clone())
324        };
325
326        let packages = metadata
327            .packages
328            .iter()
329            .filter(|p|
330                // Filter by the package name if one is selected based on the above logic.
331                if let Some(name) = &name {
332                    &p.name == name
333                } else {
334                    // Otherwise filter crates that are default members of the
335                    // workspace and that build to cdylib (wasm).
336                    metadata.workspace_default_members.contains(&p.id)
337                        && p.targets
338                            .iter()
339                            .any(|t| t.crate_types.iter().any(|c| c == "cdylib"))
340                }
341            )
342            .cloned()
343            .collect();
344
345        Ok(packages)
346    }
347
348    fn metadata(&self) -> Result<Metadata, cargo_metadata::Error> {
349        let mut cmd = MetadataCommand::new();
350        cmd.no_deps();
351        // Set the manifest path if one is provided, otherwise rely on the cargo
352        // commands default behavior of finding the nearest Cargo.toml in the
353        // current directory, or the parent directories above it.
354        if let Some(manifest_path) = &self.manifest_path {
355            cmd.manifest_path(manifest_path);
356        }
357        // Do not configure features on the metadata command, because we are
358        // only collecting non-dependency metadata, features have no impact on
359        // the output.
360        cmd.exec()
361    }
362
363    fn inject_meta(&self, target_file_path: &PathBuf) -> Result<(), Error> {
364        let mut wasm_bytes = fs::read(target_file_path).map_err(Error::ReadingWasmFile)?;
365        let xdr = self.encoded_new_meta()?;
366        wasm_gen::write_custom_section(&mut wasm_bytes, META_CUSTOM_SECTION_NAME, &xdr);
367
368        // Deleting .wasm file effectively unlinking it from /release/deps/.wasm preventing from overwrite
369        // See https://github.com/stellar/stellar-cli/issues/1694#issuecomment-2709342205
370        fs::remove_file(target_file_path).map_err(Error::DeletingArtifact)?;
371        fs::write(target_file_path, wasm_bytes).map_err(Error::WritingWasmFile)
372    }
373
374    fn encoded_new_meta(&self) -> Result<Vec<u8>, Error> {
375        let mut new_meta: Vec<ScMetaEntry> = Vec::new();
376
377        // Always inject CLI version
378        let cli_meta_entry = ScMetaEntry::ScMetaV0(ScMetaV0 {
379            key: "cliver".to_string().try_into().unwrap(),
380            val: version::one_line().clone().try_into().unwrap(),
381        });
382        new_meta.push(cli_meta_entry);
383
384        // Add args provided meta
385        for (k, v) in self.meta.clone() {
386            let key: StringM = k
387                .clone()
388                .try_into()
389                .map_err(|e| Error::MetaArg(format!("{k} is an invalid metadata key: {e}")))?;
390
391            let val: StringM = v
392                .clone()
393                .try_into()
394                .map_err(|e| Error::MetaArg(format!("{v} is an invalid metadata value: {e}")))?;
395            let meta_entry = ScMetaEntry::ScMetaV0(ScMetaV0 { key, val });
396            new_meta.push(meta_entry);
397        }
398
399        let mut buffer = Vec::new();
400        let mut writer = Limited::new(Cursor::new(&mut buffer), Limits::none());
401        for entry in new_meta {
402            entry.write_xdr(&mut writer)?;
403        }
404        Ok(buffer)
405    }
406
407    fn print_build_summary(
408        print: &Print,
409        path: &Path,
410        wasm_bytes: Vec<u8>,
411        optimized_wasm_bytes: Vec<u8>,
412    ) {
413        print.infoln("Build Summary:");
414
415        let rel_path = path
416            .strip_prefix(env::current_dir().unwrap())
417            .unwrap_or(path);
418
419        let size = wasm_bytes.len();
420        let optimized_size = optimized_wasm_bytes.len();
421
422        let size_description = if optimized_size > 0 {
423            format!("{optimized_size} bytes optimized (original size was {size} bytes)")
424        } else {
425            format!("{size} bytes")
426        };
427
428        let bytes = if optimized_size > 0 {
429            &optimized_wasm_bytes
430        } else {
431            &wasm_bytes
432        };
433
434        print.blankln(format!(
435            "Wasm File: {path} ({size_description})",
436            path = rel_path.display()
437        ));
438
439        print.blankln(format!("Wasm Hash: {}", hex::encode(Sha256::digest(bytes))));
440        print.blankln(format!("Wasm Size: {size_description}"));
441
442        let parser = wasmparser::Parser::new(0);
443        let export_names: Vec<&str> = parser
444            .parse_all(&wasm_bytes)
445            .filter_map(Result::ok)
446            .filter_map(|payload| {
447                if let wasmparser::Payload::ExportSection(exports) = payload {
448                    Some(exports)
449                } else {
450                    None
451                }
452            })
453            .flatten()
454            .filter_map(Result::ok)
455            .filter(|export| matches!(export.kind, wasmparser::ExternalKind::Func))
456            .map(|export| export.name)
457            .sorted()
458            .collect();
459
460        if export_names.is_empty() {
461            print.blankln("Exported Functions: None found");
462        } else {
463            print.blankln(format!("Exported Functions: {} found", export_names.len()));
464            for name in export_names {
465                print.blankln(format!("  • {name}"));
466            }
467        }
468
469        print.checkln("Build Complete\n");
470    }
471}
472
473/// Configure cargo/rustc to replace absolute paths in panic messages / debuginfo
474/// with relative paths.
475///
476/// This is required for reproducible builds.
477///
478/// This works for paths to crates in the registry. The compiler already does
479/// something similar for standard library paths and local paths. It may not
480/// work for crates that come from other sources, including the standard library
481/// compiled from source, though it may be possible to accomodate such cases in
482/// the future.
483///
484/// This in theory breaks the ability of debuggers to find source code, but
485/// since we are only targetting wasm, which is not typically run in a debugger,
486/// and stellar-cli only compiles contracts in release mode, the impact is on
487/// debugging is expected to be minimal.
488///
489/// This works by setting the `CARGO_BUILD_RUSTFLAGS` environment variable,
490/// with appropriate `--remap-path-prefix` option. It preserves the values of an
491/// existing `CARGO_BUILD_RUSTFLAGS` environment variable.
492///
493/// This must be done some via some variation of `RUSTFLAGS` and not as
494/// arguments to `cargo rustc` because the latter only applies to the crate
495/// directly being compiled, while `RUSTFLAGS` applies to all crates, including
496/// dependencies.
497///
498/// `CARGO_BUILD_RUSTFLAGS` is an alias for the `build.rustflags` configuration
499/// variable. Cargo automatically merges the contents of the environment variable
500/// and the variables from config files; and `build.rustflags` has the lowest
501/// priority of all the variations of rustflags that Cargo accepts. And because
502/// we merge our values with an existing `CARGO_BUILD_RUSTFLAGS`,
503/// our setting of this environment variable should not interfere with the
504/// user's ability to set rustflags in any way they want, but it does mean
505/// that if the user sets a higher-priority rustflags that our path remapping
506/// will be ignored.
507///
508/// The major downside of using `CARGO_BUILD_RUSTFLAGS` is that it is whitespace
509/// separated, which means we cannot support paths with spaces. If we encounter
510/// such paths we will emit a warning. Spaces could be accomodated by using
511/// `CARGO_ENCODED_RUSTFLAGS`, but that has high precedence over other rustflags,
512/// so we could be interfering with the user's own use of rustflags. There is
513/// no "encoded" variant of `CARGO_BUILD_RUSTFLAGS` at time of writing.
514///
515/// This assumes that paths are Unicode and that any existing `CARGO_BUILD_RUSTFLAGS`
516/// variables are Unicode. Non-Unicode paths will fail to correctly perform the
517/// the absolute path replacement. Non-Unicode `CARGO_BUILD_RUSTFLAGS` will result in the
518/// existing rustflags being ignored, which is also the behavior of
519/// Cargo itself.
520fn make_rustflags_to_remap_absolute_paths(print: &Print) -> Result<Option<String>, Error> {
521    let cargo_home = home::cargo_home().map_err(Error::CargoHome)?;
522
523    if format!("{}", cargo_home.display())
524        .find(|c: char| c.is_whitespace())
525        .is_some()
526    {
527        print.warnln("Cargo home directory contains whitespace. Dependency paths will not be remapped; builds may not be reproducible.");
528        return Ok(None);
529    }
530
531    if env::var("RUSTFLAGS").is_ok() {
532        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.");
533        return Ok(None);
534    }
535
536    if env::var("CARGO_ENCODED_RUSTFLAGS").is_ok() {
537        print.warnln("`CARGO_ENCODED_RUSTFLAGS` set. Dependency paths will not be remapped; builds may not be reproducible.");
538        return Ok(None);
539    }
540
541    let target = get_wasm_target()?;
542    let env_var_name = format!("TARGET_{target}_RUSTFLAGS");
543
544    if env::var(env_var_name.clone()).is_ok() {
545        print.warnln(format!("`{env_var_name}` set. Dependency paths will not be remapped; builds may not be reproducible."));
546        return Ok(None);
547    }
548
549    let registry_prefix = cargo_home.join("registry").join("src");
550    let registry_prefix_str = registry_prefix.display().to_string();
551    #[cfg(windows)]
552    let registry_prefix_str = registry_prefix_str.replace('\\', "/");
553    let new_rustflag = format!("--remap-path-prefix={registry_prefix_str}=");
554
555    let mut rustflags = get_rustflags().unwrap_or_default();
556    rustflags.push(new_rustflag);
557
558    let rustflags = rustflags.join(" ");
559
560    Ok(Some(rustflags))
561}
562
563/// Get any existing `CARGO_BUILD_RUSTFLAGS`, split on whitespace.
564///
565/// This conveniently ignores non-Unicode values, as does Cargo.
566fn get_rustflags() -> Option<Vec<String>> {
567    if let Ok(a) = env::var("CARGO_BUILD_RUSTFLAGS") {
568        let args = a
569            .split_whitespace()
570            .map(str::trim)
571            .filter(|s| !s.is_empty())
572            .map(str::to_string);
573        return Some(args.collect());
574    }
575
576    None
577}
578
579fn get_wasm_target() -> Result<String, Error> {
580    let Ok(current_version) = version() else {
581        return Ok(WASM_TARGET.into());
582    };
583
584    let v184 = Version::parse("1.84.0").unwrap();
585    let v182 = Version::parse("1.82.0").unwrap();
586    let v191 = Version::parse("1.91.0").unwrap();
587
588    if current_version == v191 {
589        return Err(Error::RustVersion(current_version.to_string()));
590    }
591
592    if current_version >= v182 && current_version < v184 {
593        return Err(Error::RustVersion(current_version.to_string()));
594    }
595
596    if current_version < v184 {
597        Ok(WASM_TARGET_OLD.into())
598    } else {
599        Ok(WASM_TARGET.into())
600    }
601}