1use cargo_metadata::{Metadata, MetadataCommand, Package};
2use clap::Parser;
3use itertools::Itertools;
4use rustc_version::version;
5use semver::Version;
6use sha2::{Digest, Sha256};
7use soroban_spec_tools::sanitize;
8use std::{
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(Debug, Clone)]
30pub struct BuiltContract {
31 pub name: String,
33 pub path: PathBuf,
35}
36
37#[derive(Parser, Debug, Clone)]
50#[allow(clippy::struct_excessive_bools)]
51pub struct Cmd {
52 #[arg(long)]
54 pub manifest_path: Option<std::path::PathBuf>,
55 #[arg(long)]
59 pub package: Option<String>,
60
61 #[arg(long, default_value = "release")]
63 pub profile: String,
64
65 #[arg(long, help_heading = "Features")]
67 pub features: Option<String>,
68
69 #[arg(
71 long,
72 conflicts_with = "features",
73 conflicts_with = "no_default_features",
74 help_heading = "Features"
75 )]
76 pub all_features: bool,
77
78 #[arg(long, help_heading = "Features")]
80 pub no_default_features: bool,
81
82 #[arg(long)]
89 pub out_dir: Option<std::path::PathBuf>,
90
91 #[arg(long)]
93 pub locked: bool,
94
95 #[arg(long, conflicts_with = "out_dir", help_heading = "Other")]
97 pub print_commands_only: bool,
98
99 #[command(flatten)]
100 pub build_args: BuildArgs,
101}
102
103#[derive(Parser, Debug, Clone)]
105pub struct BuildArgs {
106 #[arg(long, num_args=1, value_parser=parse_meta_arg, action=clap::ArgAction::Append, help_heading = "Metadata")]
108 pub meta: Vec<(String, String)>,
109
110 #[arg(
112 long,
113 default_value_t = true,
114 default_missing_value = "true",
115 num_args = 0..=1,
116 action = clap::ArgAction::Set,
117 )]
118 pub optimize: bool,
119}
120
121impl Default for BuildArgs {
124 fn default() -> Self {
125 Self {
126 meta: Vec::new(),
127 optimize: true,
128 }
129 }
130}
131
132pub fn parse_meta_arg(s: &str) -> Result<(String, String), Error> {
133 let parts = s.splitn(2, '=');
134
135 let (key, value) = parts
136 .map(str::trim)
137 .next_tuple()
138 .ok_or_else(|| Error::MetaArg("must be in the form 'key=value'".to_string()))?;
139
140 Ok((key.to_string(), value.to_string()))
141}
142
143#[derive(thiserror::Error, Debug)]
144pub enum Error {
145 #[error(transparent)]
146 Metadata(#[from] cargo_metadata::Error),
147
148 #[error(transparent)]
149 CargoCmd(io::Error),
150
151 #[error("exit status {0}")]
152 Exit(ExitStatus),
153
154 #[error("package {package} not found")]
155 PackageNotFound { package: String },
156
157 #[error("finding absolute path of Cargo.toml: {0}")]
158 AbsolutePath(io::Error),
159
160 #[error("creating out directory: {0}")]
161 CreatingOutDir(io::Error),
162
163 #[error("deleting existing artifact: {0}")]
164 DeletingArtifact(io::Error),
165
166 #[error("copying wasm file: {0}")]
167 CopyingWasmFile(io::Error),
168
169 #[error("getting the current directory: {0}")]
170 GettingCurrentDir(io::Error),
171
172 #[error("retrieving CARGO_HOME: {0}")]
173 CargoHome(io::Error),
174
175 #[error("reading wasm file: {0}")]
176 ReadingWasmFile(io::Error),
177
178 #[error("writing wasm file: {0}")]
179 WritingWasmFile(io::Error),
180
181 #[error("invalid meta entry: {0}")]
182 MetaArg(String),
183
184 #[error(
185 "use a rust version other than 1.81, 1.82, 1.83 or 1.91.0 to build contracts (got {0})"
186 )]
187 RustVersion(String),
188
189 #[error("invalid Cargo.toml configuration: {0}")]
190 CargoConfiguration(String),
191
192 #[error(transparent)]
193 Xdr(#[from] stellar_xdr::curr::Error),
194
195 #[cfg(feature = "additional-libs")]
196 #[error(transparent)]
197 Optimize(#[from] optimize::Error),
198
199 #[error(transparent)]
200 Wasm(#[from] wasm::Error),
201
202 #[error(transparent)]
203 SpecTools(#[from] soroban_spec_tools::contract::Error),
204
205 #[error("wasm parsing error: {0}")]
206 WasmParsing(String),
207}
208
209const WASM_TARGET: &str = "wasm32v1-none";
210const WASM_TARGET_OLD: &str = "wasm32-unknown-unknown";
211const META_CUSTOM_SECTION_NAME: &str = "contractmetav0";
212
213impl Default for Cmd {
214 fn default() -> Self {
215 Self {
216 manifest_path: None,
217 package: None,
218 profile: "release".to_string(),
219 features: None,
220 all_features: false,
221 no_default_features: false,
222 out_dir: None,
223 locked: false,
224 print_commands_only: false,
225 build_args: BuildArgs::default(),
226 }
227 }
228}
229
230impl Cmd {
231 #[allow(clippy::too_many_lines)]
233 pub fn run(&self, global_args: &global::Args) -> Result<Vec<BuiltContract>, Error> {
234 let print = Print::new(global_args.quiet);
235 let working_dir = env::current_dir().map_err(Error::GettingCurrentDir)?;
236 let metadata = self.metadata()?;
237 let packages = self.packages(&metadata)?;
238 let target_dir = &metadata.target_directory;
239
240 if !self.print_commands_only {
242 run_checks(metadata.workspace_root.as_std_path(), &self.profile)?;
243 }
244
245 if let Some(package) = &self.package {
246 if packages.is_empty() {
247 return Err(Error::PackageNotFound {
248 package: package.clone(),
249 });
250 }
251 }
252
253 let wasm_target = get_wasm_target()?;
254 let mut built_contracts = Vec::new();
255
256 for p in packages {
257 let mut cmd = Command::new("cargo");
258 cmd.stdout(Stdio::piped());
259 cmd.arg("rustc");
260 if self.locked {
261 cmd.arg("--locked");
262 }
263 let manifest_path = pathdiff::diff_paths(&p.manifest_path, &working_dir)
264 .unwrap_or(p.manifest_path.clone().into());
265 cmd.arg(format!(
266 "--manifest-path={}",
267 manifest_path.to_string_lossy()
268 ));
269 cmd.arg("--crate-type=cdylib");
270 cmd.arg(format!("--target={wasm_target}"));
271 if self.profile == "release" {
272 cmd.arg("--release");
273 } else {
274 cmd.arg(format!("--profile={}", self.profile));
275 }
276 if self.all_features {
277 cmd.arg("--all-features");
278 }
279 if self.no_default_features {
280 cmd.arg("--no-default-features");
281 }
282 if let Some(features) = self.features() {
283 let requested: HashSet<String> = features.iter().cloned().collect();
284 let available = p.features.iter().map(|f| f.0).cloned().collect();
285 let activate = requested.intersection(&available).join(",");
286 if !activate.is_empty() {
287 cmd.arg(format!("--features={activate}"));
288 }
289 }
290
291 if let Some(rustflags) = make_rustflags_to_remap_absolute_paths(&print)? {
292 cmd.env("CARGO_BUILD_RUSTFLAGS", rustflags);
293 }
294
295 cmd.env("SOROBAN_SDK_BUILD_SYSTEM_SUPPORTS_SPEC_SHAKING_V2", "1");
298
299 let cmd_str = serialize_command(&cmd);
300
301 if self.print_commands_only {
302 println!("{cmd_str}");
303 } else {
304 print.infoln(cmd_str);
305 let status = cmd.status().map_err(Error::CargoCmd)?;
306 if !status.success() {
307 return Err(Error::Exit(status));
308 }
309
310 let wasm_name = p.name.replace('-', "_");
311 let file = format!("{wasm_name}.wasm");
312 let target_file_path = Path::new(target_dir)
313 .join(&wasm_target)
314 .join(&self.profile)
315 .join(&file);
316
317 self.inject_meta(&target_file_path)?;
318 Self::filter_spec(&target_file_path)?;
319
320 let final_path = if let Some(out_dir) = &self.out_dir {
321 fs::create_dir_all(out_dir).map_err(Error::CreatingOutDir)?;
322 let out_file_path = Path::new(out_dir).join(&file);
323 fs::copy(target_file_path, &out_file_path).map_err(Error::CopyingWasmFile)?;
324 out_file_path
325 } else {
326 target_file_path
327 };
328
329 let wasm_bytes = fs::read(&final_path).map_err(Error::ReadingWasmFile)?;
330 #[cfg_attr(not(feature = "additional-libs"), allow(unused_mut))]
331 let mut optimized_wasm_bytes: Vec<u8> = Vec::new();
332
333 #[cfg(feature = "additional-libs")]
334 if self.build_args.optimize {
335 let mut path = final_path.clone();
336 path.set_extension("optimized.wasm");
337 optimize::optimize(true, vec![final_path.clone()], Some(path.clone()))?;
338 optimized_wasm_bytes = fs::read(&path).map_err(Error::ReadingWasmFile)?;
339
340 fs::remove_file(&final_path).map_err(Error::DeletingArtifact)?;
341 fs::rename(&path, &final_path).map_err(Error::CopyingWasmFile)?;
342 }
343
344 #[cfg(not(feature = "additional-libs"))]
345 if self.build_args.optimize {
346 print.warnln(
347 "Optimization skipped: stellar-cli was installed without the `additional-libs` feature. \
348 Reinstall with `--features additional-libs` to enable."
349 );
350 }
351
352 Self::print_build_summary(
353 &print,
354 &p.name,
355 &final_path,
356 wasm_bytes,
357 optimized_wasm_bytes,
358 );
359
360 built_contracts.push(BuiltContract {
361 name: p.name.clone(),
362 path: final_path,
363 });
364 }
365 }
366
367 Ok(built_contracts)
368 }
369
370 fn features(&self) -> Option<Vec<String>> {
371 self.features
372 .as_ref()
373 .map(|f| f.split(&[',', ' ']).map(String::from).collect())
374 }
375
376 fn packages(&self, metadata: &Metadata) -> Result<Vec<Package>, Error> {
377 let name = if let Some(name) = self.package.clone() {
381 Some(name)
382 } else {
383 let manifest_path = path::absolute(
388 self.manifest_path
389 .clone()
390 .unwrap_or(PathBuf::from("Cargo.toml")),
391 )
392 .map_err(Error::AbsolutePath)?;
393 metadata
394 .packages
395 .iter()
396 .find(|p| p.manifest_path == manifest_path)
397 .map(|p| p.name.clone())
398 };
399
400 let packages = metadata
401 .packages
402 .iter()
403 .filter(|p|
404 if let Some(name) = &name {
406 &p.name == name
407 } else {
408 metadata.workspace_default_members.contains(&p.id)
411 && p.targets
412 .iter()
413 .any(|t| t.crate_types.iter().any(|c| c == "cdylib"))
414 }
415 )
416 .cloned()
417 .collect();
418
419 Ok(packages)
420 }
421
422 fn metadata(&self) -> Result<Metadata, cargo_metadata::Error> {
423 let mut cmd = MetadataCommand::new();
424 cmd.no_deps();
425 if let Some(manifest_path) = &self.manifest_path {
429 cmd.manifest_path(manifest_path);
430 }
431 cmd.exec()
435 }
436
437 fn inject_meta(&self, target_file_path: &PathBuf) -> Result<(), Error> {
438 let mut wasm_bytes = fs::read(target_file_path).map_err(Error::ReadingWasmFile)?;
439 let xdr = self.encoded_new_meta()?;
440 wasm_gen::write_custom_section(&mut wasm_bytes, META_CUSTOM_SECTION_NAME, &xdr);
441
442 fs::remove_file(target_file_path).map_err(Error::DeletingArtifact)?;
445 fs::write(target_file_path, wasm_bytes).map_err(Error::WritingWasmFile)
446 }
447
448 fn filter_spec(target_file_path: &PathBuf) -> Result<(), Error> {
459 use soroban_spec_tools::contract::Spec;
460 use soroban_spec_tools::wasm::replace_custom_section;
461
462 let wasm_bytes = fs::read(target_file_path).map_err(Error::ReadingWasmFile)?;
463
464 let spec = Spec::new(&wasm_bytes)?;
466
467 if soroban_spec::shaking::spec_shaking_version_for_meta(&spec.meta) != 2 {
469 return Ok(());
470 }
471
472 let markers = soroban_spec::shaking::find_all(&wasm_bytes);
474
475 let filtered_xdr = filter_and_dedup_spec(spec.spec.clone(), &markers)?;
478
479 let new_wasm = replace_custom_section(&wasm_bytes, "contractspecv0", &filtered_xdr)
481 .map_err(|e| Error::WasmParsing(e.to_string()))?;
482
483 fs::remove_file(target_file_path).map_err(Error::DeletingArtifact)?;
485 fs::write(target_file_path, new_wasm).map_err(Error::WritingWasmFile)
486 }
487
488 fn encoded_new_meta(&self) -> Result<Vec<u8>, Error> {
489 let mut new_meta: Vec<ScMetaEntry> = Vec::new();
490
491 let cli_meta_entry = ScMetaEntry::ScMetaV0(ScMetaV0 {
493 key: "cliver".to_string().try_into().unwrap(),
494 val: version::one_line().clone().try_into().unwrap(),
495 });
496 new_meta.push(cli_meta_entry);
497
498 for (k, v) in self.build_args.meta.clone() {
500 let key: StringM = k
501 .clone()
502 .try_into()
503 .map_err(|e| Error::MetaArg(format!("{k} is an invalid metadata key: {e}")))?;
504
505 let val: StringM = v
506 .clone()
507 .try_into()
508 .map_err(|e| Error::MetaArg(format!("{v} is an invalid metadata value: {e}")))?;
509 let meta_entry = ScMetaEntry::ScMetaV0(ScMetaV0 { key, val });
510 new_meta.push(meta_entry);
511 }
512
513 let mut buffer = Vec::new();
514 let mut writer = Limited::new(Cursor::new(&mut buffer), Limits::none());
515 for entry in new_meta {
516 entry.write_xdr(&mut writer)?;
517 }
518 Ok(buffer)
519 }
520
521 fn print_build_summary(
522 print: &Print,
523 name: &str,
524 path: &Path,
525 wasm_bytes: Vec<u8>,
526 optimized_wasm_bytes: Vec<u8>,
527 ) {
528 print.infoln("Build Summary:");
529
530 let rel_path = path
531 .strip_prefix(env::current_dir().unwrap())
532 .unwrap_or(path);
533
534 let size = wasm_bytes.len();
535 let optimized_size = optimized_wasm_bytes.len();
536
537 let size_description = if optimized_size > 0 {
538 format!("{optimized_size} bytes optimized (original size was {size} bytes)")
539 } else {
540 format!("{size} bytes")
541 };
542
543 let bytes = if optimized_size > 0 {
544 &optimized_wasm_bytes
545 } else {
546 &wasm_bytes
547 };
548
549 print.blankln(format!(
550 "Wasm File: {path} ({size_description})",
551 path = rel_path.display()
552 ));
553
554 print.blankln(format!("Wasm Hash: {}", hex::encode(Sha256::digest(bytes))));
555 print.blankln(format!("Wasm Size: {size_description}"));
556
557 let parser = wasmparser::Parser::new(0);
558 let export_names: Vec<&str> = parser
559 .parse_all(&wasm_bytes)
560 .filter_map(Result::ok)
561 .filter_map(|payload| {
562 if let wasmparser::Payload::ExportSection(exports) = payload {
563 Some(exports)
564 } else {
565 None
566 }
567 })
568 .flatten()
569 .filter_map(Result::ok)
570 .filter(|export| matches!(export.kind, wasmparser::ExternalKind::Func))
571 .map(|export| export.name)
572 .sorted()
573 .collect();
574
575 if export_names.is_empty() {
576 print.blankln("Exported Functions: None found");
577 } else {
578 print.blankln(format!("Exported Functions: {} found", export_names.len()));
579 for name in export_names {
580 print.blankln(format!(" • {}", sanitize(name)));
581 }
582 }
583
584 if let Ok(spec) = soroban_spec_tools::Spec::from_wasm(bytes) {
585 for w in spec.verify() {
586 print.warnln(format!("{name}: {w}"));
587 }
588 }
589
590 print.checkln("Build Complete\n");
591 }
592}
593
594fn serialize_command(cmd: &Command) -> String {
595 let mut parts = Vec::<String>::new();
596 parts.extend(cmd.get_envs().map(|(key, val)| {
597 format!(
598 "{}={}",
599 key.to_string_lossy(),
600 shell_escape::escape(val.unwrap_or_default().to_string_lossy())
601 )
602 }));
603 parts.push(cmd.get_program().to_string_lossy().into_owned());
604 parts.extend(
605 cmd.get_args()
606 .map(OsStr::to_string_lossy)
607 .map(|a| shell_escape::escape(a).into_owned()),
608 );
609 parts.join(" ")
610}
611
612fn make_rustflags_to_remap_absolute_paths(print: &Print) -> Result<Option<String>, Error> {
660 let cargo_home = home::cargo_home().map_err(Error::CargoHome)?;
661
662 if format!("{}", cargo_home.display())
663 .find(|c: char| c.is_whitespace())
664 .is_some()
665 {
666 print.warnln("Cargo home directory contains whitespace. Dependency paths will not be remapped; builds may not be reproducible.");
667 return Ok(None);
668 }
669
670 if env::var("RUSTFLAGS").is_ok() {
671 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.");
672 return Ok(None);
673 }
674
675 if env::var("CARGO_ENCODED_RUSTFLAGS").is_ok() {
676 print.warnln("`CARGO_ENCODED_RUSTFLAGS` set. Dependency paths will not be remapped; builds may not be reproducible.");
677 return Ok(None);
678 }
679
680 let target = get_wasm_target()?;
681 let env_var_name = format!("TARGET_{target}_RUSTFLAGS");
682
683 if env::var(env_var_name.clone()).is_ok() {
684 print.warnln(format!("`{env_var_name}` set. Dependency paths will not be remapped; builds may not be reproducible."));
685 return Ok(None);
686 }
687
688 let registry_prefix = cargo_home.join("registry").join("src");
689 let registry_prefix_str = registry_prefix.display().to_string();
690 #[cfg(windows)]
691 let registry_prefix_str = registry_prefix_str.replace('\\', "/");
692 let new_rustflag = format!("--remap-path-prefix={registry_prefix_str}=");
693
694 let mut rustflags = get_rustflags().unwrap_or_default();
695 rustflags.push(new_rustflag);
696
697 let rustflags = rustflags.join(" ");
698
699 Ok(Some(rustflags))
700}
701
702fn get_rustflags() -> Option<Vec<String>> {
706 if let Ok(a) = env::var("CARGO_BUILD_RUSTFLAGS") {
707 let args = a
708 .split_whitespace()
709 .map(str::trim)
710 .filter(|s| !s.is_empty())
711 .map(str::to_string);
712 return Some(args.collect());
713 }
714
715 None
716}
717
718fn get_wasm_target() -> Result<String, Error> {
719 let Ok(current_version) = version() else {
720 return Ok(WASM_TARGET.into());
721 };
722
723 let v184 = Version::parse("1.84.0").unwrap();
724 let v182 = Version::parse("1.82.0").unwrap();
725 let v191 = Version::parse("1.91.0").unwrap();
726
727 if current_version == v191 {
728 return Err(Error::RustVersion(current_version.to_string()));
729 }
730
731 if current_version >= v182 && current_version < v184 {
732 return Err(Error::RustVersion(current_version.to_string()));
733 }
734
735 if current_version < v184 {
736 Ok(WASM_TARGET_OLD.into())
737 } else {
738 Ok(WASM_TARGET.into())
739 }
740}
741
742fn run_checks(workspace_root: &Path, profile: &str) -> Result<(), Error> {
744 let cargo_toml_path = workspace_root.join("Cargo.toml");
745
746 let cargo_toml_str = match fs::read_to_string(&cargo_toml_path) {
747 Ok(s) => s,
748 Err(e) => {
749 return Err(Error::CargoConfiguration(format!(
750 "Could not read Cargo.toml: {e}"
751 )));
752 }
753 };
754
755 let doc: toml_edit::DocumentMut = match cargo_toml_str.parse() {
756 Ok(d) => d,
757 Err(e) => {
758 return Err(Error::CargoConfiguration(format!(
759 "Could not parse Cargo.toml to run checks: {e}"
760 )));
761 }
762 };
763
764 check_overflow_checks(&doc, profile)?;
765 Ok(())
767}
768
769fn check_overflow_checks(doc: &toml_edit::DocumentMut, profile: &str) -> Result<(), Error> {
772 fn get_overflow_checks(
775 doc: &toml_edit::DocumentMut,
776 profile: &str,
777 visited: &mut Vec<String>,
778 ) -> Option<bool> {
779 if visited.contains(&profile.to_string()) {
780 return None; }
782 visited.push(profile.to_string());
783
784 let profile_section = doc.get("profile")?.get(profile)?;
785
786 if let Some(val) = profile_section
788 .get("overflow-checks")
789 .and_then(toml_edit::Item::as_bool)
790 {
791 return Some(val);
792 }
793
794 if let Some(inherits) = profile_section.get("inherits").and_then(|v| v.as_str()) {
796 return get_overflow_checks(doc, inherits, visited);
797 }
798
799 None
800 }
801
802 let mut visited = Vec::new();
803 if get_overflow_checks(doc, profile, &mut visited) == Some(true) {
804 Ok(())
805 } else {
806 Err(Error::CargoConfiguration(format!(
807 "`overflow-checks` is not enabled for profile `{profile}`. \
808 To prevent silent integer overflow, add `overflow-checks = true` to \
809 [profile.{profile}] in your Cargo.toml."
810 )))
811 }
812}
813
814#[allow(clippy::implicit_hasher)]
820pub fn filter_and_dedup_spec(
821 entries: Vec<stellar_xdr::curr::ScSpecEntry>,
822 markers: &HashSet<soroban_spec::shaking::Marker>,
823) -> Result<Vec<u8>, Error> {
824 let mut seen = HashSet::new();
825 let mut filtered_xdr = Vec::new();
826 let mut writer = Limited::new(Cursor::new(&mut filtered_xdr), Limits::none());
827 for entry in soroban_spec::shaking::filter(entries, markers) {
828 let entry_xdr = entry.to_xdr(Limits::none())?;
829 if seen.insert(entry_xdr) {
830 entry.write_xdr(&mut writer)?;
831 }
832 }
833 Ok(filtered_xdr)
834}
835
836#[cfg(test)]
837mod tests {
838 use super::*;
839
840 #[test]
841 fn serialize_command_shell_escapes_args_with_metacharacters() {
842 let raw_arg = "--manifest-path=/path/to/contract;touch PWNED;#/Cargo.toml";
843 let escaped_arg = shell_escape::escape(raw_arg.into()).into_owned();
844
845 let mut cmd = Command::new("cargo");
846 cmd.arg("rustc");
847 cmd.arg(raw_arg);
848
849 let output = serialize_command(&cmd);
850
851 assert!(
853 output.contains(&escaped_arg),
854 "expected escaped arg {escaped_arg:?} in output: {output}"
855 );
856
857 let tokens = shlex::split(&output).expect("serialize_command output must be valid shell");
860 assert!(
861 tokens.iter().any(|t| t == raw_arg),
862 "shlex round-trip failed: {raw_arg:?} not found as a single token in {tokens:?}"
863 );
864 }
865}