soroban_cli/commands/contract/
build.rs1use cargo_metadata::{Metadata, MetadataCommand, Package};
2use clap::Parser;
3use itertools::Itertools;
4use sha2::{Digest, Sha256};
5use std::{
6 borrow::Cow,
7 collections::HashSet,
8 env,
9 ffi::OsStr,
10 fmt::Debug,
11 fs, io,
12 path::{self, Path, PathBuf},
13 process::{Command, ExitStatus, Stdio},
14};
15use stellar_xdr::curr::{Limits, ScMetaEntry, ScMetaV0, StringM, WriteXdr};
16
17use crate::{commands::global, print::Print};
18
19#[derive(Parser, Debug, Clone)]
32pub struct Cmd {
33 #[arg(long)]
35 pub manifest_path: Option<std::path::PathBuf>,
36 #[arg(long)]
40 pub package: Option<String>,
41 #[arg(long, default_value = "release")]
43 pub profile: String,
44 #[arg(long, help_heading = "Features")]
46 pub features: Option<String>,
47 #[arg(
49 long,
50 conflicts_with = "features",
51 conflicts_with = "no_default_features",
52 help_heading = "Features"
53 )]
54 pub all_features: bool,
55 #[arg(long, help_heading = "Features")]
57 pub no_default_features: bool,
58 #[arg(long)]
65 pub out_dir: Option<std::path::PathBuf>,
66 #[arg(long, conflicts_with = "out_dir", help_heading = "Other")]
68 pub print_commands_only: bool,
69 #[arg(long, num_args=1, value_parser=parse_meta_arg, action=clap::ArgAction::Append, help_heading = "Metadata")]
71 pub meta: Vec<(String, String)>,
72}
73
74fn parse_meta_arg(s: &str) -> Result<(String, String), Error> {
75 let parts = s.splitn(2, '=');
76
77 let (key, value) = parts
78 .map(str::trim)
79 .next_tuple()
80 .ok_or_else(|| Error::MetaArg("must be in the form 'key=value'".to_string()))?;
81
82 Ok((key.to_string(), value.to_string()))
83}
84
85#[derive(thiserror::Error, Debug)]
86pub enum Error {
87 #[error(transparent)]
88 Metadata(#[from] cargo_metadata::Error),
89 #[error(transparent)]
90 CargoCmd(io::Error),
91 #[error("exit status {0}")]
92 Exit(ExitStatus),
93 #[error("package {package} not found")]
94 PackageNotFound { package: String },
95 #[error("finding absolute path of Cargo.toml: {0}")]
96 AbsolutePath(io::Error),
97 #[error("creating out directory: {0}")]
98 CreatingOutDir(io::Error),
99 #[error("deleting existing artifact: {0}")]
100 DeletingArtifact(io::Error),
101 #[error("copying wasm file: {0}")]
102 CopyingWasmFile(io::Error),
103 #[error("getting the current directory: {0}")]
104 GettingCurrentDir(io::Error),
105 #[error("retreiving CARGO_HOME: {0}")]
106 CargoHome(io::Error),
107 #[error("reading wasm file: {0}")]
108 ReadingWasmFile(io::Error),
109 #[error("writing wasm file: {0}")]
110 WritingWasmFile(io::Error),
111 #[error("invalid meta entry: {0}")]
112 MetaArg(String),
113}
114
115const WASM_TARGET: &str = "wasm32-unknown-unknown";
116const META_CUSTOM_SECTION_NAME: &str = "contractmetav0";
117
118impl Cmd {
119 pub fn run(&self, global_args: &global::Args) -> Result<(), Error> {
120 let print = Print::new(global_args.quiet);
121
122 let working_dir = env::current_dir().map_err(Error::GettingCurrentDir)?;
123
124 let metadata = self.metadata()?;
125 let packages = self.packages(&metadata)?;
126 let target_dir = &metadata.target_directory;
127
128 if let Some(package) = &self.package {
129 if packages.is_empty() {
130 return Err(Error::PackageNotFound {
131 package: package.clone(),
132 });
133 }
134 }
135
136 for p in packages {
137 let mut cmd = Command::new("cargo");
138 cmd.stdout(Stdio::piped());
139 cmd.arg("rustc");
140 let manifest_path = pathdiff::diff_paths(&p.manifest_path, &working_dir)
141 .unwrap_or(p.manifest_path.clone().into());
142 cmd.arg(format!(
143 "--manifest-path={}",
144 manifest_path.to_string_lossy()
145 ));
146 cmd.arg("--crate-type=cdylib");
147 cmd.arg(format!("--target={WASM_TARGET}"));
148 if self.profile == "release" {
149 cmd.arg("--release");
150 } else {
151 cmd.arg(format!("--profile={}", self.profile));
152 }
153 if self.all_features {
154 cmd.arg("--all-features");
155 }
156 if self.no_default_features {
157 cmd.arg("--no-default-features");
158 }
159 if let Some(features) = self.features() {
160 let requested: HashSet<String> = features.iter().cloned().collect();
161 let available = p.features.iter().map(|f| f.0).cloned().collect();
162 let activate = requested.intersection(&available).join(",");
163 if !activate.is_empty() {
164 cmd.arg(format!("--features={activate}"));
165 }
166 }
167
168 if let Some(rustflags) = make_rustflags_to_remap_absolute_paths(&print)? {
169 cmd.env("CARGO_BUILD_RUSTFLAGS", rustflags);
170 }
171
172 let mut cmd_str_parts = Vec::<String>::new();
173 cmd_str_parts.extend(cmd.get_envs().map(|(key, val)| {
174 format!(
175 "{}={}",
176 key.to_string_lossy(),
177 shell_escape::escape(val.unwrap_or_default().to_string_lossy())
178 )
179 }));
180 cmd_str_parts.push("cargo".to_string());
181 cmd_str_parts.extend(
182 cmd.get_args()
183 .map(OsStr::to_string_lossy)
184 .map(Cow::into_owned),
185 );
186 let cmd_str = cmd_str_parts.join(" ");
187
188 if self.print_commands_only {
189 println!("{cmd_str}");
190 } else {
191 print.infoln(cmd_str);
192 let status = cmd.status().map_err(Error::CargoCmd)?;
193 if !status.success() {
194 return Err(Error::Exit(status));
195 }
196
197 let file = format!("{}.wasm", p.name.replace('-', "_"));
198 let target_file_path = Path::new(target_dir)
199 .join(WASM_TARGET)
200 .join(&self.profile)
201 .join(&file);
202
203 self.handle_contract_metadata_args(&target_file_path)?;
204
205 let final_path = if let Some(out_dir) = &self.out_dir {
206 fs::create_dir_all(out_dir).map_err(Error::CreatingOutDir)?;
207 let out_file_path = Path::new(out_dir).join(&file);
208 fs::copy(target_file_path, &out_file_path).map_err(Error::CopyingWasmFile)?;
209 out_file_path
210 } else {
211 target_file_path
212 };
213
214 Self::print_build_summary(&print, &final_path)?;
215 }
216 }
217
218 Ok(())
219 }
220
221 fn features(&self) -> Option<Vec<String>> {
222 self.features
223 .as_ref()
224 .map(|f| f.split(&[',', ' ']).map(String::from).collect())
225 }
226
227 fn packages(&self, metadata: &Metadata) -> Result<Vec<Package>, Error> {
228 let name = if let Some(name) = self.package.clone() {
232 Some(name)
233 } else {
234 let manifest_path = path::absolute(
239 self.manifest_path
240 .clone()
241 .unwrap_or(PathBuf::from("Cargo.toml")),
242 )
243 .map_err(Error::AbsolutePath)?;
244 metadata
245 .packages
246 .iter()
247 .find(|p| p.manifest_path == manifest_path)
248 .map(|p| p.name.clone())
249 };
250
251 let packages = metadata
252 .packages
253 .iter()
254 .filter(|p|
255 if let Some(name) = &name {
257 &p.name == name
258 } else {
259 metadata.workspace_default_members.contains(&p.id)
262 && p.targets
263 .iter()
264 .any(|t| t.crate_types.iter().any(|c| c == "cdylib"))
265 }
266 )
267 .cloned()
268 .collect();
269
270 Ok(packages)
271 }
272
273 fn metadata(&self) -> Result<Metadata, cargo_metadata::Error> {
274 let mut cmd = MetadataCommand::new();
275 cmd.no_deps();
276 if let Some(manifest_path) = &self.manifest_path {
280 cmd.manifest_path(manifest_path);
281 }
282 cmd.exec()
286 }
287
288 fn handle_contract_metadata_args(&self, target_file_path: &PathBuf) -> Result<(), Error> {
289 if self.meta.is_empty() {
290 return Ok(());
291 }
292
293 let mut wasm_bytes = fs::read(target_file_path).map_err(Error::ReadingWasmFile)?;
294
295 for (k, v) in self.meta.clone() {
296 let key: StringM = k
297 .clone()
298 .try_into()
299 .map_err(|e| Error::MetaArg(format!("{k} is an invalid metadata key: {e}")))?;
300
301 let val: StringM = v
302 .clone()
303 .try_into()
304 .map_err(|e| Error::MetaArg(format!("{v} is an invalid metadata value: {e}")))?;
305 let meta_entry = ScMetaEntry::ScMetaV0(ScMetaV0 { key, val });
306 let xdr: Vec<u8> = meta_entry
307 .to_xdr(Limits::none())
308 .map_err(|e| Error::MetaArg(format!("failed to encode metadata entry: {e}")))?;
309
310 wasm_gen::write_custom_section(&mut wasm_bytes, META_CUSTOM_SECTION_NAME, &xdr);
311 }
312
313 fs::remove_file(target_file_path).map_err(Error::DeletingArtifact)?;
316 fs::write(target_file_path, wasm_bytes).map_err(Error::WritingWasmFile)
317 }
318
319 fn print_build_summary(print: &Print, target_file_path: &PathBuf) -> Result<(), Error> {
320 print.infoln("Build Summary:");
321 let rel_target_file_path = target_file_path
322 .strip_prefix(env::current_dir().unwrap())
323 .unwrap_or(target_file_path);
324 print.blankln(format!("Wasm File: {}", rel_target_file_path.display()));
325
326 let wasm_bytes = fs::read(target_file_path).map_err(Error::ReadingWasmFile)?;
327
328 print.blankln(format!(
329 "Wasm Hash: {}",
330 hex::encode(Sha256::digest(&wasm_bytes))
331 ));
332
333 let parser = wasmparser::Parser::new(0);
334 let export_names: Vec<&str> = parser
335 .parse_all(&wasm_bytes)
336 .filter_map(Result::ok)
337 .filter_map(|payload| {
338 if let wasmparser::Payload::ExportSection(exports) = payload {
339 Some(exports)
340 } else {
341 None
342 }
343 })
344 .flatten()
345 .filter_map(Result::ok)
346 .filter(|export| matches!(export.kind, wasmparser::ExternalKind::Func))
347 .map(|export| export.name)
348 .sorted()
349 .collect();
350 if export_names.is_empty() {
351 print.blankln("Exported Functions: None found");
352 } else {
353 print.blankln(format!("Exported Functions: {} found", export_names.len()));
354 for name in export_names {
355 print.blankln(format!(" • {name}"));
356 }
357 }
358 print.checkln("Build Complete");
359
360 Ok(())
361 }
362}
363
364fn make_rustflags_to_remap_absolute_paths(print: &Print) -> Result<Option<String>, Error> {
412 let cargo_home = home::cargo_home().map_err(Error::CargoHome)?;
413
414 if format!("{}", cargo_home.display())
415 .find(|c: char| c.is_whitespace())
416 .is_some()
417 {
418 print.warnln("Cargo home directory contains whitespace. Dependency paths will not be remapped; builds may not be reproducible.");
419 return Ok(None);
420 }
421
422 if env::var("RUSTFLAGS").is_ok() {
423 print.warnln("`RUSTFLAGS` set. Dependency paths will not be remapped; builds may not be reproducible.");
424 return Ok(None);
425 }
426
427 if env::var("CARGO_ENCODED_RUSTFLAGS").is_ok() {
428 print.warnln("`CARGO_ENCODED_RUSTFLAGS` set. Dependency paths will not be remapped; builds may not be reproducible.");
429 return Ok(None);
430 }
431
432 if env::var("TARGET_wasm32-unknown-unknown_RUSTFLAGS").is_ok() {
433 print.warnln("`TARGET_wasm32-unknown-unknown_RUSTFLAGS` set. Dependency paths will not be remapped; builds may not be reproducible.");
434 return Ok(None);
435 }
436
437 let registry_prefix = cargo_home.join("registry").join("src");
438 let new_rustflag = format!("--remap-path-prefix={}=", registry_prefix.display());
439
440 let mut rustflags = get_rustflags().unwrap_or_default();
441 rustflags.push(new_rustflag);
442
443 let rustflags = rustflags.join(" ");
444
445 Ok(Some(rustflags))
446}
447
448fn get_rustflags() -> Option<Vec<String>> {
452 if let Ok(a) = env::var("CARGO_BUILD_RUSTFLAGS") {
453 let args = a
454 .split_whitespace()
455 .map(str::trim)
456 .filter(|s| !s.is_empty())
457 .map(str::to_string);
458 return Some(args.collect());
459 }
460
461 None
462}