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::{Limited, Limits, ScMetaEntry, ScMetaV0, StringM, WriteXdr};
19
20#[cfg(feature = "additional-libs")]
21use crate::commands::contract::optimize;
22use crate::utils::XDR_DEPTH_LIMIT;
23use crate::{
24 commands::{
25 container::shared::{Args as ContainerArgs, RunArgs as ContainerRunArgs},
26 global, version, HEADING_CONTAINER,
27 },
28 print::Print,
29 wasm,
30};
31
32pub mod container;
33
34#[derive(Debug, Clone)]
36pub struct BuiltContract {
37 pub name: String,
39 pub path: PathBuf,
41}
42
43#[derive(Parser, Debug, Clone)]
56#[allow(clippy::struct_excessive_bools)]
57pub struct Cmd {
58 #[arg(long)]
60 pub manifest_path: Option<std::path::PathBuf>,
61 #[arg(long)]
65 pub package: Option<String>,
66
67 #[arg(long, default_value = "release")]
69 pub profile: String,
70
71 #[arg(long, help_heading = "Features")]
73 pub features: Option<String>,
74
75 #[arg(
77 long,
78 conflicts_with = "features",
79 conflicts_with = "no_default_features",
80 help_heading = "Features"
81 )]
82 pub all_features: bool,
83
84 #[arg(long, help_heading = "Features")]
86 pub no_default_features: bool,
87
88 #[arg(long)]
95 pub out_dir: Option<std::path::PathBuf>,
96
97 #[arg(long)]
99 pub locked: bool,
100
101 #[arg(long, conflicts_with = "out_dir", help_heading = "Other")]
103 pub print_commands_only: bool,
104
105 #[arg(long, help_heading = HEADING_CONTAINER)]
114 pub image: Option<String>,
115
116 #[arg(long, requires = "image", help_heading = HEADING_CONTAINER)]
122 pub pull: bool,
123
124 #[command(flatten)]
125 pub build_args: BuildArgs,
126
127 #[command(flatten, next_help_heading = HEADING_CONTAINER)]
132 pub container_args: ContainerArgs,
133
134 #[command(flatten, next_help_heading = HEADING_CONTAINER)]
137 pub run_args: ContainerRunArgs,
138}
139
140#[derive(Parser, Debug, Clone)]
142pub struct BuildArgs {
143 #[arg(long, num_args=1, value_parser=parse_meta_arg, action=clap::ArgAction::Append, help_heading = "Metadata")]
145 pub meta: Vec<(String, String)>,
146
147 #[arg(
149 long,
150 default_value_t = true,
151 default_missing_value = "true",
152 num_args = 0..=1,
153 action = clap::ArgAction::Set,
154 )]
155 pub optimize: bool,
156}
157
158impl Default for BuildArgs {
161 fn default() -> Self {
162 Self {
163 meta: Vec::new(),
164 optimize: true,
165 }
166 }
167}
168
169pub fn parse_meta_arg(s: &str) -> Result<(String, String), Error> {
170 let parts = s.splitn(2, '=');
171
172 let (key, value) = parts
173 .map(str::trim)
174 .next_tuple()
175 .ok_or_else(|| Error::MetaArg("must be in the form 'key=value'".to_string()))?;
176
177 Ok((key.to_string(), value.to_string()))
178}
179
180#[derive(thiserror::Error, Debug)]
181pub enum Error {
182 #[error(transparent)]
183 Metadata(#[from] cargo_metadata::Error),
184
185 #[error(transparent)]
186 CargoCmd(io::Error),
187
188 #[error("exit status {0}")]
189 Exit(ExitStatus),
190
191 #[error("package {package} not found")]
192 PackageNotFound { package: String },
193
194 #[error("finding absolute path of Cargo.toml: {0}")]
195 AbsolutePath(io::Error),
196
197 #[error("creating out directory: {0}")]
198 CreatingOutDir(io::Error),
199
200 #[error("deleting existing artifact: {0}")]
201 DeletingArtifact(io::Error),
202
203 #[error("copying wasm file: {0}")]
204 CopyingWasmFile(io::Error),
205
206 #[error("getting the current directory: {0}")]
207 GettingCurrentDir(io::Error),
208
209 #[error("retrieving CARGO_HOME: {0}")]
210 CargoHome(io::Error),
211
212 #[error("reading wasm file: {0}")]
213 ReadingWasmFile(io::Error),
214
215 #[error("writing wasm file: {0}")]
216 WritingWasmFile(io::Error),
217
218 #[error("invalid meta entry: {0}")]
219 MetaArg(String),
220
221 #[error(
222 "use a rust version other than 1.81, 1.82, 1.83 or 1.91.0 to build contracts (got {0})"
223 )]
224 RustVersion(String),
225
226 #[error("invalid Cargo.toml configuration: {0}")]
227 CargoConfiguration(String),
228
229 #[error(transparent)]
230 Xdr(#[from] stellar_xdr::Error),
231
232 #[cfg(feature = "additional-libs")]
233 #[error(transparent)]
234 Optimize(#[from] optimize::Error),
235
236 #[error(transparent)]
237 Wasm(#[from] wasm::Error),
238
239 #[error(transparent)]
240 SpecTools(#[from] soroban_spec_tools::contract::Error),
241
242 #[error("wasm parsing error: {0}")]
243 WasmParsing(String),
244
245 #[error(transparent)]
246 Container(#[from] container::Error),
247}
248
249pub(crate) const WASM_TARGET: &str = "wasm32v1-none";
250pub(crate) const WASM_TARGET_OLD: &str = "wasm32-unknown-unknown";
251const META_CUSTOM_SECTION_NAME: &str = "contractmetav0";
252
253impl Default for Cmd {
254 fn default() -> Self {
255 Self {
256 manifest_path: None,
257 package: None,
258 profile: "release".to_string(),
259 features: None,
260 all_features: false,
261 no_default_features: false,
262 out_dir: None,
263 locked: false,
264 print_commands_only: false,
265 image: None,
266 pull: false,
267 build_args: BuildArgs::default(),
268 container_args: ContainerArgs::default(),
269 run_args: ContainerRunArgs::default(),
270 }
271 }
272}
273
274impl Cmd {
275 #[allow(clippy::too_many_lines)]
277 pub async fn run(&self, global_args: &global::Args) -> Result<Vec<BuiltContract>, Error> {
278 let print = Print::new(global_args.quiet);
279
280 if self.image.is_some() {
282 return container::run(self, global_args, &print).await;
283 }
284
285 let working_dir = env::current_dir().map_err(Error::GettingCurrentDir)?;
286 let metadata = self.metadata()?;
287 let packages = self.packages(&metadata)?;
288 let target_dir = &metadata.target_directory;
289
290 if !self.print_commands_only {
292 run_checks(metadata.workspace_root.as_std_path(), &self.profile)?;
293 }
294
295 if let Some(package) = &self.package {
296 if packages.is_empty() {
297 return Err(Error::PackageNotFound {
298 package: package.clone(),
299 });
300 }
301 }
302
303 let wasm_target = get_wasm_target()?;
304 let mut built_contracts = Vec::new();
305
306 for p in packages {
307 let mut cmd = Command::new("cargo");
308 cmd.stdout(Stdio::piped());
309 cmd.arg("rustc");
310 if self.locked {
311 cmd.arg("--locked");
312 }
313 let manifest_path = pathdiff::diff_paths(&p.manifest_path, &working_dir)
314 .unwrap_or(p.manifest_path.clone().into());
315 cmd.arg(format!(
316 "--manifest-path={}",
317 manifest_path.to_string_lossy()
318 ));
319 cmd.arg("--crate-type=cdylib");
320 cmd.arg(format!("--target={wasm_target}"));
321 if self.profile == "release" {
322 cmd.arg("--release");
323 } else {
324 cmd.arg(format!("--profile={}", self.profile));
325 }
326 if self.all_features {
327 cmd.arg("--all-features");
328 }
329 if self.no_default_features {
330 cmd.arg("--no-default-features");
331 }
332 if let Some(features) = self.features() {
333 let requested: HashSet<String> = features.iter().cloned().collect();
334 let available = p.features.iter().map(|f| f.0).cloned().collect();
335 let activate = requested.intersection(&available).join(",");
336 if !activate.is_empty() {
337 cmd.arg(format!("--features={activate}"));
338 }
339 }
340
341 if let Some(rustflags) = make_rustflags_to_remap_absolute_paths(&print)? {
342 cmd.env("CARGO_BUILD_RUSTFLAGS", rustflags);
343 }
344
345 cmd.env("SOROBAN_SDK_BUILD_SYSTEM_SUPPORTS_SPEC_SHAKING_V2", "1");
348
349 let cmd_str = serialize_command(&cmd);
350
351 if self.print_commands_only {
352 println!("{cmd_str}");
353 } else {
354 print.infoln(cmd_str);
355 let status = cmd.status().map_err(Error::CargoCmd)?;
356 if !status.success() {
357 return Err(Error::Exit(status));
358 }
359
360 let wasm_name = p.name.replace('-', "_");
361 let file = format!("{wasm_name}.wasm");
362 let target_file_path = Path::new(target_dir)
363 .join(&wasm_target)
364 .join(&self.profile)
365 .join(&file);
366
367 self.inject_meta(&target_file_path)?;
368 Self::filter_spec(&target_file_path)?;
369
370 let final_path = if let Some(out_dir) = &self.out_dir {
371 fs::create_dir_all(out_dir).map_err(Error::CreatingOutDir)?;
372 let out_file_path = Path::new(out_dir).join(&file);
373 fs::copy(target_file_path, &out_file_path).map_err(Error::CopyingWasmFile)?;
374 out_file_path
375 } else {
376 target_file_path
377 };
378
379 let wasm_bytes = fs::read(&final_path).map_err(Error::ReadingWasmFile)?;
380 #[cfg_attr(not(feature = "additional-libs"), allow(unused_mut))]
381 let mut optimized_wasm_bytes: Vec<u8> = Vec::new();
382
383 #[cfg(feature = "additional-libs")]
384 if self.build_args.optimize {
385 let mut path = final_path.clone();
386 path.set_extension("optimized.wasm");
387 optimize::optimize(true, vec![final_path.clone()], Some(path.clone()))?;
388 optimized_wasm_bytes = fs::read(&path).map_err(Error::ReadingWasmFile)?;
389
390 fs::remove_file(&final_path).map_err(Error::DeletingArtifact)?;
391 fs::rename(&path, &final_path).map_err(Error::CopyingWasmFile)?;
392 }
393
394 #[cfg(not(feature = "additional-libs"))]
395 if self.build_args.optimize {
396 print.warnln(
397 "Optimization skipped: stellar-cli was installed without the `additional-libs` feature. \
398 Reinstall with `--features additional-libs` to enable."
399 );
400 }
401
402 Self::print_build_summary(
403 &print,
404 &p.name,
405 &final_path,
406 wasm_bytes,
407 optimized_wasm_bytes,
408 );
409
410 built_contracts.push(BuiltContract {
411 name: p.name.clone(),
412 path: final_path,
413 });
414 }
415 }
416
417 Ok(built_contracts)
418 }
419
420 fn features(&self) -> Option<Vec<String>> {
421 self.features
422 .as_ref()
423 .map(|f| f.split(&[',', ' ']).map(String::from).collect())
424 }
425
426 fn packages(&self, metadata: &Metadata) -> Result<Vec<Package>, Error> {
427 let name = if let Some(name) = self.package.clone() {
431 Some(name)
432 } else {
433 let manifest_path = path::absolute(
438 self.manifest_path
439 .clone()
440 .unwrap_or(PathBuf::from("Cargo.toml")),
441 )
442 .map_err(Error::AbsolutePath)?;
443 metadata
444 .packages
445 .iter()
446 .find(|p| p.manifest_path == manifest_path)
447 .map(|p| p.name.clone())
448 };
449
450 let packages = metadata
451 .packages
452 .iter()
453 .filter(|p|
454 if let Some(name) = &name {
456 &p.name == name
457 } else {
458 metadata.workspace_default_members.contains(&p.id)
461 && p.targets
462 .iter()
463 .any(|t| t.crate_types.iter().any(|c| c == "cdylib"))
464 }
465 )
466 .cloned()
467 .collect();
468
469 Ok(packages)
470 }
471
472 fn metadata(&self) -> Result<Metadata, cargo_metadata::Error> {
473 let mut cmd = MetadataCommand::new();
474 cmd.no_deps();
475 if let Some(manifest_path) = &self.manifest_path {
479 cmd.manifest_path(manifest_path);
480 }
481 cmd.exec()
485 }
486
487 fn inject_meta(&self, target_file_path: &PathBuf) -> Result<(), Error> {
488 let mut wasm_bytes = fs::read(target_file_path).map_err(Error::ReadingWasmFile)?;
489 let xdr = self.encoded_new_meta()?;
490 wasm_gen::write_custom_section(&mut wasm_bytes, META_CUSTOM_SECTION_NAME, &xdr);
491
492 fs::remove_file(target_file_path).map_err(Error::DeletingArtifact)?;
495 fs::write(target_file_path, wasm_bytes).map_err(Error::WritingWasmFile)
496 }
497
498 fn filter_spec(target_file_path: &PathBuf) -> Result<(), Error> {
509 use soroban_spec_tools::contract::Spec;
510 use soroban_spec_tools::wasm::replace_custom_section;
511
512 let wasm_bytes = fs::read(target_file_path).map_err(Error::ReadingWasmFile)?;
513
514 let spec = Spec::new(&wasm_bytes)?;
516
517 if soroban_spec::shaking::spec_shaking_version_for_meta(&spec.meta) != 2 {
519 return Ok(());
520 }
521
522 let markers = soroban_spec::shaking::find_all(&wasm_bytes);
524
525 let filtered_xdr = filter_and_dedup_spec(spec.spec.clone(), &markers)?;
528
529 let new_wasm = replace_custom_section(&wasm_bytes, "contractspecv0", &filtered_xdr)
531 .map_err(|e| Error::WasmParsing(e.to_string()))?;
532
533 fs::remove_file(target_file_path).map_err(Error::DeletingArtifact)?;
535 fs::write(target_file_path, new_wasm).map_err(Error::WritingWasmFile)
536 }
537
538 fn encoded_new_meta(&self) -> Result<Vec<u8>, Error> {
539 let mut new_meta: Vec<ScMetaEntry> = Vec::new();
540
541 let cli_meta_entry = ScMetaEntry::ScMetaV0(ScMetaV0 {
543 key: "cliver".to_string().try_into().unwrap(),
544 val: version::one_line().clone().try_into().unwrap(),
545 });
546 new_meta.push(cli_meta_entry);
547
548 for (k, v) in self.build_args.meta.clone() {
550 let key: StringM = k
551 .clone()
552 .try_into()
553 .map_err(|e| Error::MetaArg(format!("{k} is an invalid metadata key: {e}")))?;
554
555 let val: StringM = v
556 .clone()
557 .try_into()
558 .map_err(|e| Error::MetaArg(format!("{v} is an invalid metadata value: {e}")))?;
559 let meta_entry = ScMetaEntry::ScMetaV0(ScMetaV0 { key, val });
560 new_meta.push(meta_entry);
561 }
562
563 let mut buffer = Vec::new();
564 let mut writer = Limited::new(Cursor::new(&mut buffer), Limits::depth(XDR_DEPTH_LIMIT));
565 for entry in new_meta {
566 entry.write_xdr(&mut writer)?;
567 }
568 Ok(buffer)
569 }
570
571 fn print_build_summary(
572 print: &Print,
573 name: &str,
574 path: &Path,
575 wasm_bytes: Vec<u8>,
576 optimized_wasm_bytes: Vec<u8>,
577 ) {
578 print.infoln("Build Summary:");
579
580 let rel_path = path
581 .strip_prefix(env::current_dir().unwrap())
582 .unwrap_or(path);
583
584 let size = wasm_bytes.len();
585 let optimized_size = optimized_wasm_bytes.len();
586
587 let size_description = if optimized_size > 0 {
588 format!("{optimized_size} bytes optimized (original size was {size} bytes)")
589 } else {
590 format!("{size} bytes")
591 };
592
593 let bytes = if optimized_size > 0 {
594 &optimized_wasm_bytes
595 } else {
596 &wasm_bytes
597 };
598
599 print.blankln(format!(
600 "Wasm File: {path} ({size_description})",
601 path = rel_path.display()
602 ));
603
604 print.blankln(format!("Wasm Hash: {}", hex::encode(Sha256::digest(bytes))));
605 print.blankln(format!("Wasm Size: {size_description}"));
606
607 let parser = wasmparser::Parser::new(0);
608 let export_names: Vec<&str> = parser
609 .parse_all(&wasm_bytes)
610 .filter_map(Result::ok)
611 .filter_map(|payload| {
612 if let wasmparser::Payload::ExportSection(exports) = payload {
613 Some(exports)
614 } else {
615 None
616 }
617 })
618 .flatten()
619 .filter_map(Result::ok)
620 .filter(|export| matches!(export.kind, wasmparser::ExternalKind::Func))
621 .map(|export| export.name)
622 .sorted()
623 .collect();
624
625 if export_names.is_empty() {
626 print.blankln("Exported Functions: None found");
627 } else {
628 print.blankln(format!("Exported Functions: {} found", export_names.len()));
629 for name in export_names {
630 print.blankln(format!(" • {}", sanitize(name)));
631 }
632 }
633
634 if let Ok(spec) = soroban_spec_tools::Spec::from_wasm(bytes) {
635 for w in spec.verify() {
636 print.warnln(format!("{name}: {w}"));
637 }
638 }
639
640 print.checkln("Build Complete\n");
641 }
642}
643
644fn serialize_command(cmd: &Command) -> String {
645 let mut parts = Vec::<String>::new();
646 parts.extend(cmd.get_envs().map(|(key, val)| {
647 format!(
648 "{}={}",
649 key.to_string_lossy(),
650 shell_escape::escape(val.unwrap_or_default().to_string_lossy())
651 )
652 }));
653 parts.push(cmd.get_program().to_string_lossy().into_owned());
654 parts.extend(
655 cmd.get_args()
656 .map(OsStr::to_string_lossy)
657 .map(|a| shell_escape::escape(a).into_owned()),
658 );
659 parts.join(" ")
660}
661
662fn make_rustflags_to_remap_absolute_paths(print: &Print) -> Result<Option<String>, Error> {
710 let cargo_home = home::cargo_home().map_err(Error::CargoHome)?;
711
712 if format!("{}", cargo_home.display())
713 .find(|c: char| c.is_whitespace())
714 .is_some()
715 {
716 print.warnln("Cargo home directory contains whitespace. Dependency paths will not be remapped; builds may not be reproducible.");
717 return Ok(None);
718 }
719
720 if env::var("RUSTFLAGS").is_ok() {
721 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.");
722 return Ok(None);
723 }
724
725 if env::var("CARGO_ENCODED_RUSTFLAGS").is_ok() {
726 print.warnln("`CARGO_ENCODED_RUSTFLAGS` set. Dependency paths will not be remapped; builds may not be reproducible.");
727 return Ok(None);
728 }
729
730 let target = get_wasm_target()?;
731 let env_var_name = format!("TARGET_{target}_RUSTFLAGS");
732
733 if env::var(env_var_name.clone()).is_ok() {
734 print.warnln(format!("`{env_var_name}` set. Dependency paths will not be remapped; builds may not be reproducible."));
735 return Ok(None);
736 }
737
738 let registry_prefix = cargo_home.join("registry").join("src");
739 let registry_prefix_str = registry_prefix.display().to_string();
740 #[cfg(windows)]
741 let registry_prefix_str = registry_prefix_str.replace('\\', "/");
742 let new_rustflag = format!("--remap-path-prefix={registry_prefix_str}=");
743
744 let mut rustflags = get_rustflags().unwrap_or_default();
745 rustflags.push(new_rustflag);
746
747 let rustflags = rustflags.join(" ");
748
749 Ok(Some(rustflags))
750}
751
752fn get_rustflags() -> Option<Vec<String>> {
756 if let Ok(a) = env::var("CARGO_BUILD_RUSTFLAGS") {
757 let args = a
758 .split_whitespace()
759 .map(str::trim)
760 .filter(|s| !s.is_empty())
761 .map(str::to_string);
762 return Some(args.collect());
763 }
764
765 None
766}
767
768pub(crate) fn get_wasm_target() -> Result<String, Error> {
769 let Ok(current_version) = version() else {
770 return Ok(WASM_TARGET.into());
771 };
772
773 let v184 = Version::parse("1.84.0").unwrap();
774 let v182 = Version::parse("1.82.0").unwrap();
775 let v191 = Version::parse("1.91.0").unwrap();
776
777 if current_version == v191 {
778 return Err(Error::RustVersion(current_version.to_string()));
779 }
780
781 if current_version >= v182 && current_version < v184 {
782 return Err(Error::RustVersion(current_version.to_string()));
783 }
784
785 if current_version < v184 {
786 Ok(WASM_TARGET_OLD.into())
787 } else {
788 Ok(WASM_TARGET.into())
789 }
790}
791
792fn run_checks(workspace_root: &Path, profile: &str) -> Result<(), Error> {
794 let cargo_toml_path = workspace_root.join("Cargo.toml");
795
796 let cargo_toml_str = match fs::read_to_string(&cargo_toml_path) {
797 Ok(s) => s,
798 Err(e) => {
799 return Err(Error::CargoConfiguration(format!(
800 "Could not read Cargo.toml: {e}"
801 )));
802 }
803 };
804
805 let doc: toml_edit::DocumentMut = match cargo_toml_str.parse() {
806 Ok(d) => d,
807 Err(e) => {
808 return Err(Error::CargoConfiguration(format!(
809 "Could not parse Cargo.toml to run checks: {e}"
810 )));
811 }
812 };
813
814 check_overflow_checks(&doc, profile)?;
815 Ok(())
817}
818
819fn check_overflow_checks(doc: &toml_edit::DocumentMut, profile: &str) -> Result<(), Error> {
822 fn get_overflow_checks(
825 doc: &toml_edit::DocumentMut,
826 profile: &str,
827 visited: &mut Vec<String>,
828 ) -> Option<bool> {
829 if visited.contains(&profile.to_string()) {
830 return None; }
832 visited.push(profile.to_string());
833
834 let profile_section = doc.get("profile")?.get(profile)?;
835
836 if let Some(val) = profile_section
838 .get("overflow-checks")
839 .and_then(toml_edit::Item::as_bool)
840 {
841 return Some(val);
842 }
843
844 if let Some(inherits) = profile_section.get("inherits").and_then(|v| v.as_str()) {
846 return get_overflow_checks(doc, inherits, visited);
847 }
848
849 None
850 }
851
852 let mut visited = Vec::new();
853 if get_overflow_checks(doc, profile, &mut visited) == Some(true) {
854 Ok(())
855 } else {
856 Err(Error::CargoConfiguration(format!(
857 "`overflow-checks` is not enabled for profile `{profile}`. \
858 To prevent silent integer overflow, add `overflow-checks = true` to \
859 [profile.{profile}] in your Cargo.toml."
860 )))
861 }
862}
863
864#[allow(clippy::implicit_hasher)]
870pub fn filter_and_dedup_spec(
871 entries: Vec<stellar_xdr::ScSpecEntry>,
872 markers: &HashSet<soroban_spec::shaking::Marker>,
873) -> Result<Vec<u8>, Error> {
874 let mut seen = HashSet::new();
875 let mut filtered_xdr = Vec::new();
876 let mut writer = Limited::new(
877 Cursor::new(&mut filtered_xdr),
878 Limits::depth(XDR_DEPTH_LIMIT),
879 );
880 for entry in soroban_spec::shaking::filter(entries, markers) {
881 let entry_xdr = entry.to_xdr(Limits::depth(XDR_DEPTH_LIMIT))?;
882 if seen.insert(entry_xdr) {
883 entry.write_xdr(&mut writer)?;
884 }
885 }
886 Ok(filtered_xdr)
887}
888
889#[cfg(test)]
890mod tests {
891 use super::*;
892
893 #[test]
894 fn image_flag_parses_with_tag_and_container_options() {
895 let cmd = Cmd::try_parse_from([
896 "build",
897 "--image",
898 "docker.io/stellar/stellar-cli:latest",
899 "--meta",
900 "field=value",
901 "--engine",
902 "docker",
903 "--cpus",
904 "2",
905 ])
906 .expect("--image with a tag ref and container options must parse");
907 assert_eq!(
908 cmd.image.as_deref(),
909 Some("docker.io/stellar/stellar-cli:latest")
910 );
911 assert_eq!(
912 cmd.build_args.meta,
913 vec![("field".to_string(), "value".to_string())]
914 );
915 assert_eq!(cmd.run_args.cpus, Some(2));
916 }
917
918 #[test]
919 fn image_defaults_to_none() {
920 let cmd = Cmd::try_parse_from(["build"]).unwrap();
921 assert!(cmd.image.is_none());
922 }
923
924 #[test]
925 fn pull_requires_image() {
926 let cmd = Cmd::try_parse_from([
927 "build",
928 "--image",
929 "docker.io/stellar/stellar-cli:latest",
930 "--pull",
931 ])
932 .expect("--pull with --image must parse");
933 assert!(cmd.pull);
934
935 assert!(Cmd::try_parse_from(["build", "--pull"]).is_err());
937 }
938
939 #[test]
940 fn serialize_command_shell_escapes_args_with_metacharacters() {
941 let raw_arg = "--manifest-path=/path/to/contract;touch PWNED;#/Cargo.toml";
942 let escaped_arg = shell_escape::escape(raw_arg.into()).into_owned();
943
944 let mut cmd = Command::new("cargo");
945 cmd.arg("rustc");
946 cmd.arg(raw_arg);
947
948 let output = serialize_command(&cmd);
949
950 assert!(
952 output.contains(&escaped_arg),
953 "expected escaped arg {escaped_arg:?} in output: {output}"
954 );
955
956 let tokens = shlex::split(&output).expect("serialize_command output must be valid shell");
959 assert!(
960 tokens.iter().any(|t| t == raw_arg),
961 "shlex round-trip failed: {raw_arg:?} not found as a single token in {tokens:?}"
962 );
963 }
964}