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, io,
14    path::{self, Path, PathBuf},
15    process::{Command, ExitStatus, Stdio},
16};
17use stellar_xdr::curr::{Limits, ScMetaEntry, ScMetaV0, StringM, WriteXdr};
18
19use crate::{commands::global, print::Print};
20
21/// Build a contract from source
22///
23/// Builds all crates that are referenced by the cargo manifest (Cargo.toml)
24/// that have cdylib as their crate-type. Crates are built for the wasm32
25/// target. Unless configured otherwise, crates are built with their default
26/// features and with their release profile.
27///
28/// In workspaces builds all crates unless a package name is specified, or the
29/// command is executed from the sub-directory of a workspace crate.
30///
31/// To view the commands that will be executed, without executing them, use the
32/// --print-commands-only option.
33#[derive(Parser, Debug, Clone)]
34pub struct Cmd {
35    /// Path to Cargo.toml
36    #[arg(long)]
37    pub manifest_path: Option<std::path::PathBuf>,
38    /// Package to build
39    ///
40    /// If omitted, all packages that build for crate-type cdylib are built.
41    #[arg(long)]
42    pub package: Option<String>,
43    /// Build with the specified profile
44    #[arg(long, default_value = "release")]
45    pub profile: String,
46    /// Build with the list of features activated, space or comma separated
47    #[arg(long, help_heading = "Features")]
48    pub features: Option<String>,
49    /// Build with the all features activated
50    #[arg(
51        long,
52        conflicts_with = "features",
53        conflicts_with = "no_default_features",
54        help_heading = "Features"
55    )]
56    pub all_features: bool,
57    /// Build with the default feature not activated
58    #[arg(long, help_heading = "Features")]
59    pub no_default_features: bool,
60    /// Directory to copy wasm files to
61    ///
62    /// If provided, wasm files can be found in the cargo target directory, and
63    /// the specified directory.
64    ///
65    /// If ommitted, wasm files are written only to the cargo target directory.
66    #[arg(long)]
67    pub out_dir: Option<std::path::PathBuf>,
68    /// Print commands to build without executing them
69    #[arg(long, conflicts_with = "out_dir", help_heading = "Other")]
70    pub print_commands_only: bool,
71    /// Add key-value to contract meta (adds the meta to the `contractmetav0` custom section)
72    #[arg(long, num_args=1, value_parser=parse_meta_arg, action=clap::ArgAction::Append, help_heading = "Metadata")]
73    pub meta: Vec<(String, String)>,
74}
75
76fn parse_meta_arg(s: &str) -> Result<(String, String), Error> {
77    let parts = s.splitn(2, '=');
78
79    let (key, value) = parts
80        .map(str::trim)
81        .next_tuple()
82        .ok_or_else(|| Error::MetaArg("must be in the form 'key=value'".to_string()))?;
83
84    Ok((key.to_string(), value.to_string()))
85}
86
87#[derive(thiserror::Error, Debug)]
88pub enum Error {
89    #[error(transparent)]
90    Metadata(#[from] cargo_metadata::Error),
91    #[error(transparent)]
92    CargoCmd(io::Error),
93    #[error("exit status {0}")]
94    Exit(ExitStatus),
95    #[error("package {package} not found")]
96    PackageNotFound { package: String },
97    #[error("finding absolute path of Cargo.toml: {0}")]
98    AbsolutePath(io::Error),
99    #[error("creating out directory: {0}")]
100    CreatingOutDir(io::Error),
101    #[error("deleting existing artifact: {0}")]
102    DeletingArtifact(io::Error),
103    #[error("copying wasm file: {0}")]
104    CopyingWasmFile(io::Error),
105    #[error("getting the current directory: {0}")]
106    GettingCurrentDir(io::Error),
107    #[error("retreiving CARGO_HOME: {0}")]
108    CargoHome(io::Error),
109    #[error("reading wasm file: {0}")]
110    ReadingWasmFile(io::Error),
111    #[error("writing wasm file: {0}")]
112    WritingWasmFile(io::Error),
113    #[error("invalid meta entry: {0}")]
114    MetaArg(String),
115    #[error("use rust 1.81 or 1.84+ to build contracts (got {0})")]
116    RustVersion(String),
117}
118
119const WASM_TARGET: &str = "wasm32v1-none";
120const WASM_TARGET_OLD: &str = "wasm32-unknown-unknown";
121const META_CUSTOM_SECTION_NAME: &str = "contractmetav0";
122
123impl Cmd {
124    pub fn run(&self, global_args: &global::Args) -> Result<(), Error> {
125        let print = Print::new(global_args.quiet);
126        let working_dir = env::current_dir().map_err(Error::GettingCurrentDir)?;
127        let metadata = self.metadata()?;
128        let packages = self.packages(&metadata)?;
129        let target_dir = &metadata.target_directory;
130
131        if let Some(package) = &self.package {
132            if packages.is_empty() {
133                return Err(Error::PackageNotFound {
134                    package: package.clone(),
135                });
136            }
137        }
138
139        let wasm_target = get_wasm_target()?;
140
141        for p in packages {
142            let mut cmd = Command::new("cargo");
143            cmd.stdout(Stdio::piped());
144            cmd.arg("rustc");
145            let manifest_path = pathdiff::diff_paths(&p.manifest_path, &working_dir)
146                .unwrap_or(p.manifest_path.clone().into());
147            cmd.arg(format!(
148                "--manifest-path={}",
149                manifest_path.to_string_lossy()
150            ));
151            cmd.arg("--crate-type=cdylib");
152            cmd.arg(format!("--target={wasm_target}"));
153            if self.profile == "release" {
154                cmd.arg("--release");
155            } else {
156                cmd.arg(format!("--profile={}", self.profile));
157            }
158            if self.all_features {
159                cmd.arg("--all-features");
160            }
161            if self.no_default_features {
162                cmd.arg("--no-default-features");
163            }
164            if let Some(features) = self.features() {
165                let requested: HashSet<String> = features.iter().cloned().collect();
166                let available = p.features.iter().map(|f| f.0).cloned().collect();
167                let activate = requested.intersection(&available).join(",");
168                if !activate.is_empty() {
169                    cmd.arg(format!("--features={activate}"));
170                }
171            }
172
173            if let Some(rustflags) = make_rustflags_to_remap_absolute_paths(&print)? {
174                cmd.env("CARGO_BUILD_RUSTFLAGS", rustflags);
175            }
176
177            let mut cmd_str_parts = Vec::<String>::new();
178            cmd_str_parts.extend(cmd.get_envs().map(|(key, val)| {
179                format!(
180                    "{}={}",
181                    key.to_string_lossy(),
182                    shell_escape::escape(val.unwrap_or_default().to_string_lossy())
183                )
184            }));
185            cmd_str_parts.push("cargo".to_string());
186            cmd_str_parts.extend(
187                cmd.get_args()
188                    .map(OsStr::to_string_lossy)
189                    .map(Cow::into_owned),
190            );
191            let cmd_str = cmd_str_parts.join(" ");
192
193            if self.print_commands_only {
194                println!("{cmd_str}");
195            } else {
196                print.infoln(cmd_str);
197                let status = cmd.status().map_err(Error::CargoCmd)?;
198                if !status.success() {
199                    return Err(Error::Exit(status));
200                }
201
202                let file = format!("{}.wasm", p.name.replace('-', "_"));
203                let target_file_path = Path::new(target_dir)
204                    .join(&wasm_target)
205                    .join(&self.profile)
206                    .join(&file);
207
208                self.handle_contract_metadata_args(&target_file_path)?;
209
210                let final_path = if let Some(out_dir) = &self.out_dir {
211                    fs::create_dir_all(out_dir).map_err(Error::CreatingOutDir)?;
212                    let out_file_path = Path::new(out_dir).join(&file);
213                    fs::copy(target_file_path, &out_file_path).map_err(Error::CopyingWasmFile)?;
214                    out_file_path
215                } else {
216                    target_file_path
217                };
218
219                Self::print_build_summary(&print, &final_path)?;
220            }
221        }
222
223        Ok(())
224    }
225
226    fn features(&self) -> Option<Vec<String>> {
227        self.features
228            .as_ref()
229            .map(|f| f.split(&[',', ' ']).map(String::from).collect())
230    }
231
232    fn packages(&self, metadata: &Metadata) -> Result<Vec<Package>, Error> {
233        // Filter by the package name if one is provided, or by the package that
234        // matches the manifest path if the manifest path matches a specific
235        // package.
236        let name = if let Some(name) = self.package.clone() {
237            Some(name)
238        } else {
239            // When matching a package based on the manifest path, match against the
240            // absolute path because the paths in the metadata are absolute. Match
241            // against a manifest in the current working directory if no manifest is
242            // specified.
243            let manifest_path = path::absolute(
244                self.manifest_path
245                    .clone()
246                    .unwrap_or(PathBuf::from("Cargo.toml")),
247            )
248            .map_err(Error::AbsolutePath)?;
249            metadata
250                .packages
251                .iter()
252                .find(|p| p.manifest_path == manifest_path)
253                .map(|p| p.name.clone())
254        };
255
256        let packages = metadata
257            .packages
258            .iter()
259            .filter(|p|
260                // Filter by the package name if one is selected based on the above logic.
261                if let Some(name) = &name {
262                    &p.name == name
263                } else {
264                    // Otherwise filter crates that are default members of the
265                    // workspace and that build to cdylib (wasm).
266                    metadata.workspace_default_members.contains(&p.id)
267                        && p.targets
268                            .iter()
269                            .any(|t| t.crate_types.iter().any(|c| c == "cdylib"))
270                }
271            )
272            .cloned()
273            .collect();
274
275        Ok(packages)
276    }
277
278    fn metadata(&self) -> Result<Metadata, cargo_metadata::Error> {
279        let mut cmd = MetadataCommand::new();
280        cmd.no_deps();
281        // Set the manifest path if one is provided, otherwise rely on the cargo
282        // commands default behavior of finding the nearest Cargo.toml in the
283        // current directory, or the parent directories above it.
284        if let Some(manifest_path) = &self.manifest_path {
285            cmd.manifest_path(manifest_path);
286        }
287        // Do not configure features on the metadata command, because we are
288        // only collecting non-dependency metadata, features have no impact on
289        // the output.
290        cmd.exec()
291    }
292
293    fn handle_contract_metadata_args(&self, target_file_path: &PathBuf) -> Result<(), Error> {
294        if self.meta.is_empty() {
295            return Ok(());
296        }
297
298        let mut wasm_bytes = fs::read(target_file_path).map_err(Error::ReadingWasmFile)?;
299
300        for (k, v) in self.meta.clone() {
301            let key: StringM = k
302                .clone()
303                .try_into()
304                .map_err(|e| Error::MetaArg(format!("{k} is an invalid metadata key: {e}")))?;
305
306            let val: StringM = v
307                .clone()
308                .try_into()
309                .map_err(|e| Error::MetaArg(format!("{v} is an invalid metadata value: {e}")))?;
310            let meta_entry = ScMetaEntry::ScMetaV0(ScMetaV0 { key, val });
311            let xdr: Vec<u8> = meta_entry
312                .to_xdr(Limits::none())
313                .map_err(|e| Error::MetaArg(format!("failed to encode metadata entry: {e}")))?;
314
315            wasm_gen::write_custom_section(&mut wasm_bytes, META_CUSTOM_SECTION_NAME, &xdr);
316        }
317
318        // Deleting .wasm file effectively unlinking it from /release/deps/.wasm preventing from overwrite
319        // See https://github.com/stellar/stellar-cli/issues/1694#issuecomment-2709342205
320        fs::remove_file(target_file_path).map_err(Error::DeletingArtifact)?;
321        fs::write(target_file_path, wasm_bytes).map_err(Error::WritingWasmFile)
322    }
323
324    fn print_build_summary(print: &Print, target_file_path: &PathBuf) -> Result<(), Error> {
325        print.infoln("Build Summary:");
326        let rel_target_file_path = target_file_path
327            .strip_prefix(env::current_dir().unwrap())
328            .unwrap_or(target_file_path);
329        print.blankln(format!("Wasm File: {}", rel_target_file_path.display()));
330
331        let wasm_bytes = fs::read(target_file_path).map_err(Error::ReadingWasmFile)?;
332
333        print.blankln(format!(
334            "Wasm Hash: {}",
335            hex::encode(Sha256::digest(&wasm_bytes))
336        ));
337
338        let parser = wasmparser::Parser::new(0);
339        let export_names: Vec<&str> = parser
340            .parse_all(&wasm_bytes)
341            .filter_map(Result::ok)
342            .filter_map(|payload| {
343                if let wasmparser::Payload::ExportSection(exports) = payload {
344                    Some(exports)
345                } else {
346                    None
347                }
348            })
349            .flatten()
350            .filter_map(Result::ok)
351            .filter(|export| matches!(export.kind, wasmparser::ExternalKind::Func))
352            .map(|export| export.name)
353            .sorted()
354            .collect();
355        if export_names.is_empty() {
356            print.blankln("Exported Functions: None found");
357        } else {
358            print.blankln(format!("Exported Functions: {} found", export_names.len()));
359            for name in export_names {
360                print.blankln(format!("  • {name}"));
361            }
362        }
363        print.checkln("Build Complete");
364
365        Ok(())
366    }
367}
368
369/// Configure cargo/rustc to replace absolute paths in panic messages / debuginfo
370/// with relative paths.
371///
372/// This is required for reproducible builds.
373///
374/// This works for paths to crates in the registry. The compiler already does
375/// something similar for standard library paths and local paths. It may not
376/// work for crates that come from other sources, including the standard library
377/// compiled from source, though it may be possible to accomodate such cases in
378/// the future.
379///
380/// This in theory breaks the ability of debuggers to find source code, but
381/// since we are only targetting wasm, which is not typically run in a debugger,
382/// and stellar-cli only compiles contracts in release mode, the impact is on
383/// debugging is expected to be minimal.
384///
385/// This works by setting the `CARGO_BUILD_RUSTFLAGS` environment variable,
386/// with appropriate `--remap-path-prefix` option. It preserves the values of an
387/// existing `CARGO_BUILD_RUSTFLAGS` environment variable.
388///
389/// This must be done some via some variation of `RUSTFLAGS` and not as
390/// arguments to `cargo rustc` because the latter only applies to the crate
391/// directly being compiled, while `RUSTFLAGS` applies to all crates, including
392/// dependencies.
393///
394/// `CARGO_BUILD_RUSTFLAGS` is an alias for the `build.rustflags` configuration
395/// variable. Cargo automatically merges the contents of the environment variable
396/// and the variables from config files; and `build.rustflags` has the lowest
397/// priority of all the variations of rustflags that Cargo accepts. And because
398/// we merge our values with an existing `CARGO_BUILD_RUSTFLAGS`,
399/// our setting of this environment variable should not interfere with the
400/// user's ability to set rustflags in any way they want, but it does mean
401/// that if the user sets a higher-priority rustflags that our path remapping
402/// will be ignored.
403///
404/// The major downside of using `CARGO_BUILD_RUSTFLAGS` is that it is whitespace
405/// separated, which means we cannot support paths with spaces. If we encounter
406/// such paths we will emit a warning. Spaces could be accomodated by using
407/// `CARGO_ENCODED_RUSTFLAGS`, but that has high precedence over other rustflags,
408/// so we could be interfering with the user's own use of rustflags. There is
409/// no "encoded" variant of `CARGO_BUILD_RUSTFLAGS` at time of writing.
410///
411/// This assumes that paths are Unicode and that any existing `CARGO_BUILD_RUSTFLAGS`
412/// variables are Unicode. Non-Unicode paths will fail to correctly perform the
413/// the absolute path replacement. Non-Unicode `CARGO_BUILD_RUSTFLAGS` will result in the
414/// existing rustflags being ignored, which is also the behavior of
415/// Cargo itself.
416fn make_rustflags_to_remap_absolute_paths(print: &Print) -> Result<Option<String>, Error> {
417    let cargo_home = home::cargo_home().map_err(Error::CargoHome)?;
418
419    if format!("{}", cargo_home.display())
420        .find(|c: char| c.is_whitespace())
421        .is_some()
422    {
423        print.warnln("Cargo home directory contains whitespace. Dependency paths will not be remapped; builds may not be reproducible.");
424        return Ok(None);
425    }
426
427    if env::var("RUSTFLAGS").is_ok() {
428        print.warnln("`RUSTFLAGS` set. Dependency paths will not be remapped; builds may not be reproducible.");
429        return Ok(None);
430    }
431
432    if env::var("CARGO_ENCODED_RUSTFLAGS").is_ok() {
433        print.warnln("`CARGO_ENCODED_RUSTFLAGS` set. Dependency paths will not be remapped; builds may not be reproducible.");
434        return Ok(None);
435    }
436
437    let target = get_wasm_target()?;
438    let env_var_name = format!("TARGET_{target}_RUSTFLAGS");
439
440    if env::var(env_var_name.clone()).is_ok() {
441        print.warnln(format!("`{env_var_name}` set. Dependency paths will not be remapped; builds may not be reproducible."));
442        return Ok(None);
443    }
444
445    let registry_prefix = cargo_home.join("registry").join("src");
446    let registry_prefix_str = registry_prefix.display().to_string();
447    #[cfg(windows)]
448    let registry_prefix_str = registry_prefix_str.replace('\\', "/");
449    let new_rustflag = format!("--remap-path-prefix={registry_prefix_str}=");
450
451    let mut rustflags = get_rustflags().unwrap_or_default();
452    rustflags.push(new_rustflag);
453
454    let rustflags = rustflags.join(" ");
455
456    Ok(Some(rustflags))
457}
458
459/// Get any existing `CARGO_BUILD_RUSTFLAGS`, split on whitespace.
460///
461/// This conveniently ignores non-Unicode values, as does Cargo.
462fn get_rustflags() -> Option<Vec<String>> {
463    if let Ok(a) = env::var("CARGO_BUILD_RUSTFLAGS") {
464        let args = a
465            .split_whitespace()
466            .map(str::trim)
467            .filter(|s| !s.is_empty())
468            .map(str::to_string);
469        return Some(args.collect());
470    }
471
472    None
473}
474
475fn get_wasm_target() -> Result<String, Error> {
476    let Ok(current_version) = version() else {
477        return Ok(WASM_TARGET.into());
478    };
479
480    let v184 = Version::parse("1.84.0").unwrap();
481    let v182 = Version::parse("1.82.0").unwrap();
482
483    if current_version >= v182 && current_version < v184 {
484        return Err(Error::RustVersion(current_version.to_string()));
485    }
486
487    if current_version < v184 {
488        Ok(WASM_TARGET_OLD.into())
489    } else {
490        Ok(WASM_TARGET.into())
491    }
492}