soroban_cli/commands/contract/
build.rs

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