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,
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#[derive(Parser, Debug, Clone)]
41#[allow(clippy::struct_excessive_bools)]
42pub struct Cmd {
43 #[arg(long)]
45 pub manifest_path: Option<std::path::PathBuf>,
46 #[arg(long)]
50 pub package: Option<String>,
51
52 #[arg(long, default_value = "release")]
54 pub profile: String,
55
56 #[arg(long, help_heading = "Features")]
58 pub features: Option<String>,
59
60 #[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 #[arg(long, help_heading = "Features")]
71 pub no_default_features: bool,
72
73 #[arg(long)]
80 pub out_dir: Option<std::path::PathBuf>,
81
82 #[arg(long, conflicts_with = "out_dir", help_heading = "Other")]
84 pub print_commands_only: bool,
85
86 #[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 #[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("retrieving 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("invalid Cargo.toml configuration: {0}")]
157 CargoConfiguration(String),
158
159 #[error(transparent)]
160 Xdr(#[from] stellar_xdr::curr::Error),
161
162 #[cfg(feature = "additional-libs")]
163 #[error(transparent)]
164 Optimize(#[from] optimize::Error),
165
166 #[error(transparent)]
167 Wasm(#[from] wasm::Error),
168}
169
170const WASM_TARGET: &str = "wasm32v1-none";
171const WASM_TARGET_OLD: &str = "wasm32-unknown-unknown";
172const META_CUSTOM_SECTION_NAME: &str = "contractmetav0";
173
174impl Cmd {
175 #[allow(clippy::too_many_lines)]
176 pub fn run(&self, global_args: &global::Args) -> Result<(), Error> {
177 let print = Print::new(global_args.quiet);
178 let working_dir = env::current_dir().map_err(Error::GettingCurrentDir)?;
179 let metadata = self.metadata()?;
180 let packages = self.packages(&metadata)?;
181 let target_dir = &metadata.target_directory;
182
183 if !self.print_commands_only {
185 run_checks(metadata.workspace_root.as_std_path(), &self.profile)?;
186 }
187
188 if let Some(package) = &self.package {
189 if packages.is_empty() {
190 return Err(Error::PackageNotFound {
191 package: package.clone(),
192 });
193 }
194 }
195
196 let wasm_target = get_wasm_target()?;
197
198 for p in packages {
199 let mut cmd = Command::new("cargo");
200 cmd.stdout(Stdio::piped());
201 cmd.arg("rustc");
202 let manifest_path = pathdiff::diff_paths(&p.manifest_path, &working_dir)
203 .unwrap_or(p.manifest_path.clone().into());
204 cmd.arg(format!(
205 "--manifest-path={}",
206 manifest_path.to_string_lossy()
207 ));
208 cmd.arg("--crate-type=cdylib");
209 cmd.arg(format!("--target={wasm_target}"));
210 if self.profile == "release" {
211 cmd.arg("--release");
212 } else {
213 cmd.arg(format!("--profile={}", self.profile));
214 }
215 if self.all_features {
216 cmd.arg("--all-features");
217 }
218 if self.no_default_features {
219 cmd.arg("--no-default-features");
220 }
221 if let Some(features) = self.features() {
222 let requested: HashSet<String> = features.iter().cloned().collect();
223 let available = p.features.iter().map(|f| f.0).cloned().collect();
224 let activate = requested.intersection(&available).join(",");
225 if !activate.is_empty() {
226 cmd.arg(format!("--features={activate}"));
227 }
228 }
229
230 if let Some(rustflags) = make_rustflags_to_remap_absolute_paths(&print)? {
231 cmd.env("CARGO_BUILD_RUSTFLAGS", rustflags);
232 }
233
234 let mut cmd_str_parts = Vec::<String>::new();
235 cmd_str_parts.extend(cmd.get_envs().map(|(key, val)| {
236 format!(
237 "{}={}",
238 key.to_string_lossy(),
239 shell_escape::escape(val.unwrap_or_default().to_string_lossy())
240 )
241 }));
242 cmd_str_parts.push("cargo".to_string());
243 cmd_str_parts.extend(
244 cmd.get_args()
245 .map(OsStr::to_string_lossy)
246 .map(Cow::into_owned),
247 );
248 let cmd_str = cmd_str_parts.join(" ");
249
250 if self.print_commands_only {
251 println!("{cmd_str}");
252 } else {
253 print.infoln(cmd_str);
254 let status = cmd.status().map_err(Error::CargoCmd)?;
255 if !status.success() {
256 return Err(Error::Exit(status));
257 }
258
259 let wasm_name = p.name.replace('-', "_");
260 let file = format!("{wasm_name}.wasm");
261 let target_file_path = Path::new(target_dir)
262 .join(&wasm_target)
263 .join(&self.profile)
264 .join(&file);
265
266 self.inject_meta(&target_file_path)?;
267
268 let final_path = if let Some(out_dir) = &self.out_dir {
269 fs::create_dir_all(out_dir).map_err(Error::CreatingOutDir)?;
270 let out_file_path = Path::new(out_dir).join(&file);
271 fs::copy(target_file_path, &out_file_path).map_err(Error::CopyingWasmFile)?;
272 out_file_path
273 } else {
274 target_file_path
275 };
276
277 let wasm_bytes = fs::read(&final_path).map_err(Error::ReadingWasmFile)?;
278 #[cfg_attr(not(feature = "additional-libs"), allow(unused_mut))]
279 let mut optimized_wasm_bytes: Vec<u8> = Vec::new();
280
281 #[cfg(feature = "additional-libs")]
282 if self.optimize {
283 let mut path = final_path.clone();
284 path.set_extension("optimized.wasm");
285 optimize::optimize(true, vec![final_path.clone()], Some(path.clone()))?;
286 optimized_wasm_bytes = fs::read(&path).map_err(Error::ReadingWasmFile)?;
287
288 fs::remove_file(&final_path).map_err(Error::DeletingArtifact)?;
289 fs::rename(&path, &final_path).map_err(Error::CopyingWasmFile)?;
290 }
291
292 #[cfg(not(feature = "additional-libs"))]
293 if self.optimize {
294 return Err(Error::OptimizeFeatureNotEnabled);
295 }
296
297 Self::print_build_summary(&print, &final_path, wasm_bytes, optimized_wasm_bytes);
298 }
299 }
300
301 Ok(())
302 }
303
304 fn features(&self) -> Option<Vec<String>> {
305 self.features
306 .as_ref()
307 .map(|f| f.split(&[',', ' ']).map(String::from).collect())
308 }
309
310 fn packages(&self, metadata: &Metadata) -> Result<Vec<Package>, Error> {
311 let name = if let Some(name) = self.package.clone() {
315 Some(name)
316 } else {
317 let manifest_path = path::absolute(
322 self.manifest_path
323 .clone()
324 .unwrap_or(PathBuf::from("Cargo.toml")),
325 )
326 .map_err(Error::AbsolutePath)?;
327 metadata
328 .packages
329 .iter()
330 .find(|p| p.manifest_path == manifest_path)
331 .map(|p| p.name.clone())
332 };
333
334 let packages = metadata
335 .packages
336 .iter()
337 .filter(|p|
338 if let Some(name) = &name {
340 &p.name == name
341 } else {
342 metadata.workspace_default_members.contains(&p.id)
345 && p.targets
346 .iter()
347 .any(|t| t.crate_types.iter().any(|c| c == "cdylib"))
348 }
349 )
350 .cloned()
351 .collect();
352
353 Ok(packages)
354 }
355
356 fn metadata(&self) -> Result<Metadata, cargo_metadata::Error> {
357 let mut cmd = MetadataCommand::new();
358 cmd.no_deps();
359 if let Some(manifest_path) = &self.manifest_path {
363 cmd.manifest_path(manifest_path);
364 }
365 cmd.exec()
369 }
370
371 fn inject_meta(&self, target_file_path: &PathBuf) -> Result<(), Error> {
372 let mut wasm_bytes = fs::read(target_file_path).map_err(Error::ReadingWasmFile)?;
373 let xdr = self.encoded_new_meta()?;
374 wasm_gen::write_custom_section(&mut wasm_bytes, META_CUSTOM_SECTION_NAME, &xdr);
375
376 fs::remove_file(target_file_path).map_err(Error::DeletingArtifact)?;
379 fs::write(target_file_path, wasm_bytes).map_err(Error::WritingWasmFile)
380 }
381
382 fn encoded_new_meta(&self) -> Result<Vec<u8>, Error> {
383 let mut new_meta: Vec<ScMetaEntry> = Vec::new();
384
385 let cli_meta_entry = ScMetaEntry::ScMetaV0(ScMetaV0 {
387 key: "cliver".to_string().try_into().unwrap(),
388 val: version::one_line().clone().try_into().unwrap(),
389 });
390 new_meta.push(cli_meta_entry);
391
392 for (k, v) in self.meta.clone() {
394 let key: StringM = k
395 .clone()
396 .try_into()
397 .map_err(|e| Error::MetaArg(format!("{k} is an invalid metadata key: {e}")))?;
398
399 let val: StringM = v
400 .clone()
401 .try_into()
402 .map_err(|e| Error::MetaArg(format!("{v} is an invalid metadata value: {e}")))?;
403 let meta_entry = ScMetaEntry::ScMetaV0(ScMetaV0 { key, val });
404 new_meta.push(meta_entry);
405 }
406
407 let mut buffer = Vec::new();
408 let mut writer = Limited::new(Cursor::new(&mut buffer), Limits::none());
409 for entry in new_meta {
410 entry.write_xdr(&mut writer)?;
411 }
412 Ok(buffer)
413 }
414
415 fn print_build_summary(
416 print: &Print,
417 path: &Path,
418 wasm_bytes: Vec<u8>,
419 optimized_wasm_bytes: Vec<u8>,
420 ) {
421 print.infoln("Build Summary:");
422
423 let rel_path = path
424 .strip_prefix(env::current_dir().unwrap())
425 .unwrap_or(path);
426
427 let size = wasm_bytes.len();
428 let optimized_size = optimized_wasm_bytes.len();
429
430 let size_description = if optimized_size > 0 {
431 format!("{optimized_size} bytes optimized (original size was {size} bytes)")
432 } else {
433 format!("{size} bytes")
434 };
435
436 let bytes = if optimized_size > 0 {
437 &optimized_wasm_bytes
438 } else {
439 &wasm_bytes
440 };
441
442 print.blankln(format!(
443 "Wasm File: {path} ({size_description})",
444 path = rel_path.display()
445 ));
446
447 print.blankln(format!("Wasm Hash: {}", hex::encode(Sha256::digest(bytes))));
448 print.blankln(format!("Wasm Size: {size_description}"));
449
450 let parser = wasmparser::Parser::new(0);
451 let export_names: Vec<&str> = parser
452 .parse_all(&wasm_bytes)
453 .filter_map(Result::ok)
454 .filter_map(|payload| {
455 if let wasmparser::Payload::ExportSection(exports) = payload {
456 Some(exports)
457 } else {
458 None
459 }
460 })
461 .flatten()
462 .filter_map(Result::ok)
463 .filter(|export| matches!(export.kind, wasmparser::ExternalKind::Func))
464 .map(|export| export.name)
465 .sorted()
466 .collect();
467
468 if export_names.is_empty() {
469 print.blankln("Exported Functions: None found");
470 } else {
471 print.blankln(format!("Exported Functions: {} found", export_names.len()));
472 for name in export_names {
473 print.blankln(format!(" • {name}"));
474 }
475 }
476
477 print.checkln("Build Complete\n");
478 }
479}
480
481fn make_rustflags_to_remap_absolute_paths(print: &Print) -> Result<Option<String>, Error> {
529 let cargo_home = home::cargo_home().map_err(Error::CargoHome)?;
530
531 if format!("{}", cargo_home.display())
532 .find(|c: char| c.is_whitespace())
533 .is_some()
534 {
535 print.warnln("Cargo home directory contains whitespace. Dependency paths will not be remapped; builds may not be reproducible.");
536 return Ok(None);
537 }
538
539 if env::var("RUSTFLAGS").is_ok() {
540 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.");
541 return Ok(None);
542 }
543
544 if env::var("CARGO_ENCODED_RUSTFLAGS").is_ok() {
545 print.warnln("`CARGO_ENCODED_RUSTFLAGS` set. Dependency paths will not be remapped; builds may not be reproducible.");
546 return Ok(None);
547 }
548
549 let target = get_wasm_target()?;
550 let env_var_name = format!("TARGET_{target}_RUSTFLAGS");
551
552 if env::var(env_var_name.clone()).is_ok() {
553 print.warnln(format!("`{env_var_name}` set. Dependency paths will not be remapped; builds may not be reproducible."));
554 return Ok(None);
555 }
556
557 let registry_prefix = cargo_home.join("registry").join("src");
558 let registry_prefix_str = registry_prefix.display().to_string();
559 #[cfg(windows)]
560 let registry_prefix_str = registry_prefix_str.replace('\\', "/");
561 let new_rustflag = format!("--remap-path-prefix={registry_prefix_str}=");
562
563 let mut rustflags = get_rustflags().unwrap_or_default();
564 rustflags.push(new_rustflag);
565
566 let rustflags = rustflags.join(" ");
567
568 Ok(Some(rustflags))
569}
570
571fn get_rustflags() -> Option<Vec<String>> {
575 if let Ok(a) = env::var("CARGO_BUILD_RUSTFLAGS") {
576 let args = a
577 .split_whitespace()
578 .map(str::trim)
579 .filter(|s| !s.is_empty())
580 .map(str::to_string);
581 return Some(args.collect());
582 }
583
584 None
585}
586
587fn get_wasm_target() -> Result<String, Error> {
588 let Ok(current_version) = version() else {
589 return Ok(WASM_TARGET.into());
590 };
591
592 let v184 = Version::parse("1.84.0").unwrap();
593 let v182 = Version::parse("1.82.0").unwrap();
594 let v191 = Version::parse("1.91.0").unwrap();
595
596 if current_version == v191 {
597 return Err(Error::RustVersion(current_version.to_string()));
598 }
599
600 if current_version >= v182 && current_version < v184 {
601 return Err(Error::RustVersion(current_version.to_string()));
602 }
603
604 if current_version < v184 {
605 Ok(WASM_TARGET_OLD.into())
606 } else {
607 Ok(WASM_TARGET.into())
608 }
609}
610
611fn run_checks(workspace_root: &Path, profile: &str) -> Result<(), Error> {
613 let cargo_toml_path = workspace_root.join("Cargo.toml");
614
615 let cargo_toml_str = match fs::read_to_string(&cargo_toml_path) {
616 Ok(s) => s,
617 Err(e) => {
618 return Err(Error::CargoConfiguration(format!(
619 "Could not read Cargo.toml: {e}"
620 )));
621 }
622 };
623
624 let doc: toml_edit::DocumentMut = match cargo_toml_str.parse() {
625 Ok(d) => d,
626 Err(e) => {
627 return Err(Error::CargoConfiguration(format!(
628 "Could not parse Cargo.toml to run checks: {e}"
629 )));
630 }
631 };
632
633 check_overflow_checks(&doc, profile)?;
634 Ok(())
636}
637
638fn check_overflow_checks(doc: &toml_edit::DocumentMut, profile: &str) -> Result<(), Error> {
641 fn get_overflow_checks(
644 doc: &toml_edit::DocumentMut,
645 profile: &str,
646 visited: &mut Vec<String>,
647 ) -> Option<bool> {
648 if visited.contains(&profile.to_string()) {
649 return None; }
651 visited.push(profile.to_string());
652
653 let profile_section = doc.get("profile")?.get(profile)?;
654
655 if let Some(val) = profile_section
657 .get("overflow-checks")
658 .and_then(toml_edit::Item::as_bool)
659 {
660 return Some(val);
661 }
662
663 if let Some(inherits) = profile_section.get("inherits").and_then(|v| v.as_str()) {
665 return get_overflow_checks(doc, inherits, visited);
666 }
667
668 None
669 }
670
671 let mut visited = Vec::new();
672 if get_overflow_checks(doc, profile, &mut visited) == Some(true) {
673 Ok(())
674 } else {
675 Err(Error::CargoConfiguration(format!(
676 "`overflow-checks` is not enabled for profile `{profile}`. \
677 To prevent silent integer overflow, add `overflow-checks = true` to \
678 [profile.{profile}] in your Cargo.toml."
679 )))
680 }
681}