soroban_cli/commands/contract/
build.rs1use 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#[derive(Parser, Debug, Clone)]
34pub struct Cmd {
35 #[arg(long)]
37 pub manifest_path: Option<std::path::PathBuf>,
38 #[arg(long)]
42 pub package: Option<String>,
43 #[arg(long, default_value = "release")]
45 pub profile: String,
46 #[arg(long, help_heading = "Features")]
48 pub features: Option<String>,
49 #[arg(
51 long,
52 conflicts_with = "features",
53 conflicts_with = "no_default_features",
54 help_heading = "Features"
55 )]
56 pub all_features: bool,
57 #[arg(long, help_heading = "Features")]
59 pub no_default_features: bool,
60 #[arg(long)]
67 pub out_dir: Option<std::path::PathBuf>,
68 #[arg(long, conflicts_with = "out_dir", help_heading = "Other")]
70 pub print_commands_only: bool,
71 #[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 let name = if let Some(name) = self.package.clone() {
237 Some(name)
238 } else {
239 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 if let Some(name) = &name {
262 &p.name == name
263 } else {
264 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 if let Some(manifest_path) = &self.manifest_path {
285 cmd.manifest_path(manifest_path);
286 }
287 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 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
369fn 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
459fn 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}