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,
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, Default)]
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 #[cfg_attr(feature = "additional-libs", arg(long))]
112 #[cfg_attr(not(feature = "additional-libs"), arg(long, hide = true))]
113 pub optimize: bool,
114}
115
116pub fn parse_meta_arg(s: &str) -> Result<(String, String), Error> {
117 let parts = s.splitn(2, '=');
118
119 let (key, value) = parts
120 .map(str::trim)
121 .next_tuple()
122 .ok_or_else(|| Error::MetaArg("must be in the form 'key=value'".to_string()))?;
123
124 Ok((key.to_string(), value.to_string()))
125}
126
127#[derive(thiserror::Error, Debug)]
128pub enum Error {
129 #[error(transparent)]
130 Metadata(#[from] cargo_metadata::Error),
131
132 #[error(transparent)]
133 CargoCmd(io::Error),
134
135 #[error("exit status {0}")]
136 Exit(ExitStatus),
137
138 #[error("package {package} not found")]
139 PackageNotFound { package: String },
140
141 #[error("finding absolute path of Cargo.toml: {0}")]
142 AbsolutePath(io::Error),
143
144 #[error("creating out directory: {0}")]
145 CreatingOutDir(io::Error),
146
147 #[error("deleting existing artifact: {0}")]
148 DeletingArtifact(io::Error),
149
150 #[error("copying wasm file: {0}")]
151 CopyingWasmFile(io::Error),
152
153 #[error("getting the current directory: {0}")]
154 GettingCurrentDir(io::Error),
155
156 #[error("retrieving CARGO_HOME: {0}")]
157 CargoHome(io::Error),
158
159 #[error("reading wasm file: {0}")]
160 ReadingWasmFile(io::Error),
161
162 #[error("writing wasm file: {0}")]
163 WritingWasmFile(io::Error),
164
165 #[error("invalid meta entry: {0}")]
166 MetaArg(String),
167
168 #[error(
169 "use a rust version other than 1.81, 1.82, 1.83 or 1.91.0 to build contracts (got {0})"
170 )]
171 RustVersion(String),
172
173 #[error("must install with \"additional-libs\" feature.")]
174 OptimizeFeatureNotEnabled,
175
176 #[error("invalid Cargo.toml configuration: {0}")]
177 CargoConfiguration(String),
178
179 #[error(transparent)]
180 Xdr(#[from] stellar_xdr::curr::Error),
181
182 #[cfg(feature = "additional-libs")]
183 #[error(transparent)]
184 Optimize(#[from] optimize::Error),
185
186 #[error(transparent)]
187 Wasm(#[from] wasm::Error),
188
189 #[error(transparent)]
190 SpecTools(#[from] soroban_spec_tools::contract::Error),
191
192 #[error("wasm parsing error: {0}")]
193 WasmParsing(String),
194}
195
196const WASM_TARGET: &str = "wasm32v1-none";
197const WASM_TARGET_OLD: &str = "wasm32-unknown-unknown";
198const META_CUSTOM_SECTION_NAME: &str = "contractmetav0";
199
200impl Default for Cmd {
201 fn default() -> Self {
202 Self {
203 manifest_path: None,
204 package: None,
205 profile: "release".to_string(),
206 features: None,
207 all_features: false,
208 no_default_features: false,
209 out_dir: None,
210 locked: false,
211 print_commands_only: false,
212 build_args: BuildArgs::default(),
213 }
214 }
215}
216
217impl Cmd {
218 #[allow(clippy::too_many_lines)]
220 pub fn run(&self, global_args: &global::Args) -> Result<Vec<BuiltContract>, Error> {
221 let print = Print::new(global_args.quiet);
222 let working_dir = env::current_dir().map_err(Error::GettingCurrentDir)?;
223 let metadata = self.metadata()?;
224 let packages = self.packages(&metadata)?;
225 let target_dir = &metadata.target_directory;
226
227 if !self.print_commands_only {
229 run_checks(metadata.workspace_root.as_std_path(), &self.profile)?;
230 }
231
232 if let Some(package) = &self.package {
233 if packages.is_empty() {
234 return Err(Error::PackageNotFound {
235 package: package.clone(),
236 });
237 }
238 }
239
240 let wasm_target = get_wasm_target()?;
241 let mut built_contracts = Vec::new();
242
243 for p in packages {
244 let mut cmd = Command::new("cargo");
245 cmd.stdout(Stdio::piped());
246 cmd.arg("rustc");
247 if self.locked {
248 cmd.arg("--locked");
249 }
250 let manifest_path = pathdiff::diff_paths(&p.manifest_path, &working_dir)
251 .unwrap_or(p.manifest_path.clone().into());
252 cmd.arg(format!(
253 "--manifest-path={}",
254 manifest_path.to_string_lossy()
255 ));
256 cmd.arg("--crate-type=cdylib");
257 cmd.arg(format!("--target={wasm_target}"));
258 if self.profile == "release" {
259 cmd.arg("--release");
260 } else {
261 cmd.arg(format!("--profile={}", self.profile));
262 }
263 if self.all_features {
264 cmd.arg("--all-features");
265 }
266 if self.no_default_features {
267 cmd.arg("--no-default-features");
268 }
269 if let Some(features) = self.features() {
270 let requested: HashSet<String> = features.iter().cloned().collect();
271 let available = p.features.iter().map(|f| f.0).cloned().collect();
272 let activate = requested.intersection(&available).join(",");
273 if !activate.is_empty() {
274 cmd.arg(format!("--features={activate}"));
275 }
276 }
277
278 if let Some(rustflags) = make_rustflags_to_remap_absolute_paths(&print)? {
279 cmd.env("CARGO_BUILD_RUSTFLAGS", rustflags);
280 }
281
282 cmd.env("SOROBAN_SDK_BUILD_SYSTEM_SUPPORTS_SPEC_SHAKING_V2", "1");
285
286 let mut cmd_str_parts = Vec::<String>::new();
287 cmd_str_parts.extend(cmd.get_envs().map(|(key, val)| {
288 format!(
289 "{}={}",
290 key.to_string_lossy(),
291 shell_escape::escape(val.unwrap_or_default().to_string_lossy())
292 )
293 }));
294 cmd_str_parts.push("cargo".to_string());
295 cmd_str_parts.extend(
296 cmd.get_args()
297 .map(OsStr::to_string_lossy)
298 .map(Cow::into_owned),
299 );
300 let cmd_str = cmd_str_parts.join(" ");
301
302 if self.print_commands_only {
303 println!("{cmd_str}");
304 } else {
305 print.infoln(cmd_str);
306 let status = cmd.status().map_err(Error::CargoCmd)?;
307 if !status.success() {
308 return Err(Error::Exit(status));
309 }
310
311 let wasm_name = p.name.replace('-', "_");
312 let file = format!("{wasm_name}.wasm");
313 let target_file_path = Path::new(target_dir)
314 .join(&wasm_target)
315 .join(&self.profile)
316 .join(&file);
317
318 self.inject_meta(&target_file_path)?;
319 Self::filter_spec(&target_file_path)?;
320
321 let final_path = if let Some(out_dir) = &self.out_dir {
322 fs::create_dir_all(out_dir).map_err(Error::CreatingOutDir)?;
323 let out_file_path = Path::new(out_dir).join(&file);
324 fs::copy(target_file_path, &out_file_path).map_err(Error::CopyingWasmFile)?;
325 out_file_path
326 } else {
327 target_file_path
328 };
329
330 let wasm_bytes = fs::read(&final_path).map_err(Error::ReadingWasmFile)?;
331 #[cfg_attr(not(feature = "additional-libs"), allow(unused_mut))]
332 let mut optimized_wasm_bytes: Vec<u8> = Vec::new();
333
334 #[cfg(feature = "additional-libs")]
335 if self.build_args.optimize {
336 let mut path = final_path.clone();
337 path.set_extension("optimized.wasm");
338 optimize::optimize(true, vec![final_path.clone()], Some(path.clone()))?;
339 optimized_wasm_bytes = fs::read(&path).map_err(Error::ReadingWasmFile)?;
340
341 fs::remove_file(&final_path).map_err(Error::DeletingArtifact)?;
342 fs::rename(&path, &final_path).map_err(Error::CopyingWasmFile)?;
343 }
344
345 #[cfg(not(feature = "additional-libs"))]
346 if self.build_args.optimize {
347 return Err(Error::OptimizeFeatureNotEnabled);
348 }
349
350 Self::print_build_summary(
351 &print,
352 &p.name,
353 &final_path,
354 wasm_bytes,
355 optimized_wasm_bytes,
356 );
357
358 built_contracts.push(BuiltContract {
359 name: p.name.clone(),
360 path: final_path,
361 });
362 }
363 }
364
365 Ok(built_contracts)
366 }
367
368 fn features(&self) -> Option<Vec<String>> {
369 self.features
370 .as_ref()
371 .map(|f| f.split(&[',', ' ']).map(String::from).collect())
372 }
373
374 fn packages(&self, metadata: &Metadata) -> Result<Vec<Package>, Error> {
375 let name = if let Some(name) = self.package.clone() {
379 Some(name)
380 } else {
381 let manifest_path = path::absolute(
386 self.manifest_path
387 .clone()
388 .unwrap_or(PathBuf::from("Cargo.toml")),
389 )
390 .map_err(Error::AbsolutePath)?;
391 metadata
392 .packages
393 .iter()
394 .find(|p| p.manifest_path == manifest_path)
395 .map(|p| p.name.clone())
396 };
397
398 let packages = metadata
399 .packages
400 .iter()
401 .filter(|p|
402 if let Some(name) = &name {
404 &p.name == name
405 } else {
406 metadata.workspace_default_members.contains(&p.id)
409 && p.targets
410 .iter()
411 .any(|t| t.crate_types.iter().any(|c| c == "cdylib"))
412 }
413 )
414 .cloned()
415 .collect();
416
417 Ok(packages)
418 }
419
420 fn metadata(&self) -> Result<Metadata, cargo_metadata::Error> {
421 let mut cmd = MetadataCommand::new();
422 cmd.no_deps();
423 if let Some(manifest_path) = &self.manifest_path {
427 cmd.manifest_path(manifest_path);
428 }
429 cmd.exec()
433 }
434
435 fn inject_meta(&self, target_file_path: &PathBuf) -> Result<(), Error> {
436 let mut wasm_bytes = fs::read(target_file_path).map_err(Error::ReadingWasmFile)?;
437 let xdr = self.encoded_new_meta()?;
438 wasm_gen::write_custom_section(&mut wasm_bytes, META_CUSTOM_SECTION_NAME, &xdr);
439
440 fs::remove_file(target_file_path).map_err(Error::DeletingArtifact)?;
443 fs::write(target_file_path, wasm_bytes).map_err(Error::WritingWasmFile)
444 }
445
446 fn filter_spec(target_file_path: &PathBuf) -> Result<(), Error> {
457 use soroban_spec_tools::contract::Spec;
458 use soroban_spec_tools::wasm::replace_custom_section;
459
460 let wasm_bytes = fs::read(target_file_path).map_err(Error::ReadingWasmFile)?;
461
462 let spec = Spec::new(&wasm_bytes)?;
464
465 if soroban_spec::shaking::spec_shaking_version_for_meta(&spec.meta) != 2 {
467 return Ok(());
468 }
469
470 let markers = soroban_spec::shaking::find_all(&wasm_bytes);
472
473 let filtered_xdr = filter_and_dedup_spec(spec.spec.clone(), &markers)?;
476
477 let new_wasm = replace_custom_section(&wasm_bytes, "contractspecv0", &filtered_xdr)
479 .map_err(|e| Error::WasmParsing(e.to_string()))?;
480
481 fs::remove_file(target_file_path).map_err(Error::DeletingArtifact)?;
483 fs::write(target_file_path, new_wasm).map_err(Error::WritingWasmFile)
484 }
485
486 fn encoded_new_meta(&self) -> Result<Vec<u8>, Error> {
487 let mut new_meta: Vec<ScMetaEntry> = Vec::new();
488
489 let cli_meta_entry = ScMetaEntry::ScMetaV0(ScMetaV0 {
491 key: "cliver".to_string().try_into().unwrap(),
492 val: version::one_line().clone().try_into().unwrap(),
493 });
494 new_meta.push(cli_meta_entry);
495
496 for (k, v) in self.build_args.meta.clone() {
498 let key: StringM = k
499 .clone()
500 .try_into()
501 .map_err(|e| Error::MetaArg(format!("{k} is an invalid metadata key: {e}")))?;
502
503 let val: StringM = v
504 .clone()
505 .try_into()
506 .map_err(|e| Error::MetaArg(format!("{v} is an invalid metadata value: {e}")))?;
507 let meta_entry = ScMetaEntry::ScMetaV0(ScMetaV0 { key, val });
508 new_meta.push(meta_entry);
509 }
510
511 let mut buffer = Vec::new();
512 let mut writer = Limited::new(Cursor::new(&mut buffer), Limits::none());
513 for entry in new_meta {
514 entry.write_xdr(&mut writer)?;
515 }
516 Ok(buffer)
517 }
518
519 fn print_build_summary(
520 print: &Print,
521 name: &str,
522 path: &Path,
523 wasm_bytes: Vec<u8>,
524 optimized_wasm_bytes: Vec<u8>,
525 ) {
526 print.infoln("Build Summary:");
527
528 let rel_path = path
529 .strip_prefix(env::current_dir().unwrap())
530 .unwrap_or(path);
531
532 let size = wasm_bytes.len();
533 let optimized_size = optimized_wasm_bytes.len();
534
535 let size_description = if optimized_size > 0 {
536 format!("{optimized_size} bytes optimized (original size was {size} bytes)")
537 } else {
538 format!("{size} bytes")
539 };
540
541 let bytes = if optimized_size > 0 {
542 &optimized_wasm_bytes
543 } else {
544 &wasm_bytes
545 };
546
547 print.blankln(format!(
548 "Wasm File: {path} ({size_description})",
549 path = rel_path.display()
550 ));
551
552 print.blankln(format!("Wasm Hash: {}", hex::encode(Sha256::digest(bytes))));
553 print.blankln(format!("Wasm Size: {size_description}"));
554
555 let parser = wasmparser::Parser::new(0);
556 let export_names: Vec<&str> = parser
557 .parse_all(&wasm_bytes)
558 .filter_map(Result::ok)
559 .filter_map(|payload| {
560 if let wasmparser::Payload::ExportSection(exports) = payload {
561 Some(exports)
562 } else {
563 None
564 }
565 })
566 .flatten()
567 .filter_map(Result::ok)
568 .filter(|export| matches!(export.kind, wasmparser::ExternalKind::Func))
569 .map(|export| export.name)
570 .sorted()
571 .collect();
572
573 if export_names.is_empty() {
574 print.blankln("Exported Functions: None found");
575 } else {
576 print.blankln(format!("Exported Functions: {} found", export_names.len()));
577 for name in export_names {
578 print.blankln(format!(" • {name}"));
579 }
580 }
581
582 if let Ok(spec) = soroban_spec_tools::Spec::from_wasm(bytes) {
583 for w in spec.verify() {
584 print.warnln(format!("{name}: {w}"));
585 }
586 }
587
588 print.checkln("Build Complete\n");
589 }
590}
591
592fn make_rustflags_to_remap_absolute_paths(print: &Print) -> Result<Option<String>, Error> {
640 let cargo_home = home::cargo_home().map_err(Error::CargoHome)?;
641
642 if format!("{}", cargo_home.display())
643 .find(|c: char| c.is_whitespace())
644 .is_some()
645 {
646 print.warnln("Cargo home directory contains whitespace. Dependency paths will not be remapped; builds may not be reproducible.");
647 return Ok(None);
648 }
649
650 if env::var("RUSTFLAGS").is_ok() {
651 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.");
652 return Ok(None);
653 }
654
655 if env::var("CARGO_ENCODED_RUSTFLAGS").is_ok() {
656 print.warnln("`CARGO_ENCODED_RUSTFLAGS` set. Dependency paths will not be remapped; builds may not be reproducible.");
657 return Ok(None);
658 }
659
660 let target = get_wasm_target()?;
661 let env_var_name = format!("TARGET_{target}_RUSTFLAGS");
662
663 if env::var(env_var_name.clone()).is_ok() {
664 print.warnln(format!("`{env_var_name}` set. Dependency paths will not be remapped; builds may not be reproducible."));
665 return Ok(None);
666 }
667
668 let registry_prefix = cargo_home.join("registry").join("src");
669 let registry_prefix_str = registry_prefix.display().to_string();
670 #[cfg(windows)]
671 let registry_prefix_str = registry_prefix_str.replace('\\', "/");
672 let new_rustflag = format!("--remap-path-prefix={registry_prefix_str}=");
673
674 let mut rustflags = get_rustflags().unwrap_or_default();
675 rustflags.push(new_rustflag);
676
677 let rustflags = rustflags.join(" ");
678
679 Ok(Some(rustflags))
680}
681
682fn get_rustflags() -> Option<Vec<String>> {
686 if let Ok(a) = env::var("CARGO_BUILD_RUSTFLAGS") {
687 let args = a
688 .split_whitespace()
689 .map(str::trim)
690 .filter(|s| !s.is_empty())
691 .map(str::to_string);
692 return Some(args.collect());
693 }
694
695 None
696}
697
698fn get_wasm_target() -> Result<String, Error> {
699 let Ok(current_version) = version() else {
700 return Ok(WASM_TARGET.into());
701 };
702
703 let v184 = Version::parse("1.84.0").unwrap();
704 let v182 = Version::parse("1.82.0").unwrap();
705 let v191 = Version::parse("1.91.0").unwrap();
706
707 if current_version == v191 {
708 return Err(Error::RustVersion(current_version.to_string()));
709 }
710
711 if current_version >= v182 && current_version < v184 {
712 return Err(Error::RustVersion(current_version.to_string()));
713 }
714
715 if current_version < v184 {
716 Ok(WASM_TARGET_OLD.into())
717 } else {
718 Ok(WASM_TARGET.into())
719 }
720}
721
722fn run_checks(workspace_root: &Path, profile: &str) -> Result<(), Error> {
724 let cargo_toml_path = workspace_root.join("Cargo.toml");
725
726 let cargo_toml_str = match fs::read_to_string(&cargo_toml_path) {
727 Ok(s) => s,
728 Err(e) => {
729 return Err(Error::CargoConfiguration(format!(
730 "Could not read Cargo.toml: {e}"
731 )));
732 }
733 };
734
735 let doc: toml_edit::DocumentMut = match cargo_toml_str.parse() {
736 Ok(d) => d,
737 Err(e) => {
738 return Err(Error::CargoConfiguration(format!(
739 "Could not parse Cargo.toml to run checks: {e}"
740 )));
741 }
742 };
743
744 check_overflow_checks(&doc, profile)?;
745 Ok(())
747}
748
749fn check_overflow_checks(doc: &toml_edit::DocumentMut, profile: &str) -> Result<(), Error> {
752 fn get_overflow_checks(
755 doc: &toml_edit::DocumentMut,
756 profile: &str,
757 visited: &mut Vec<String>,
758 ) -> Option<bool> {
759 if visited.contains(&profile.to_string()) {
760 return None; }
762 visited.push(profile.to_string());
763
764 let profile_section = doc.get("profile")?.get(profile)?;
765
766 if let Some(val) = profile_section
768 .get("overflow-checks")
769 .and_then(toml_edit::Item::as_bool)
770 {
771 return Some(val);
772 }
773
774 if let Some(inherits) = profile_section.get("inherits").and_then(|v| v.as_str()) {
776 return get_overflow_checks(doc, inherits, visited);
777 }
778
779 None
780 }
781
782 let mut visited = Vec::new();
783 if get_overflow_checks(doc, profile, &mut visited) == Some(true) {
784 Ok(())
785 } else {
786 Err(Error::CargoConfiguration(format!(
787 "`overflow-checks` is not enabled for profile `{profile}`. \
788 To prevent silent integer overflow, add `overflow-checks = true` to \
789 [profile.{profile}] in your Cargo.toml."
790 )))
791 }
792}
793
794#[allow(clippy::implicit_hasher)]
800pub fn filter_and_dedup_spec(
801 entries: Vec<stellar_xdr::curr::ScSpecEntry>,
802 markers: &HashSet<soroban_spec::shaking::Marker>,
803) -> Result<Vec<u8>, Error> {
804 let mut seen = HashSet::new();
805 let mut filtered_xdr = Vec::new();
806 let mut writer = Limited::new(Cursor::new(&mut filtered_xdr), Limits::none());
807 for entry in soroban_spec::shaking::filter(entries, markers) {
808 let entry_xdr = entry.to_xdr(Limits::none())?;
809 if seen.insert(entry_xdr) {
810 entry.write_xdr(&mut writer)?;
811 }
812 }
813 Ok(filtered_xdr)
814}