use cargo_metadata::{Metadata, MetadataCommand, Package};
use clap::Parser;
use itertools::Itertools;
use rustc_version::version;
use semver::Version;
use sha2::{Digest, Sha256};
use std::{
borrow::Cow,
collections::HashSet,
env,
ffi::OsStr,
fmt::Debug,
fs,
io::{self, Cursor},
path::{self, Path, PathBuf},
process::{Command, ExitStatus, Stdio},
};
use stellar_xdr::curr::{Limited, Limits, ScMetaEntry, ScMetaV0, StringM, WriteXdr};
#[cfg(feature = "additional-libs")]
use crate::commands::contract::optimize;
use crate::{
commands::{global, version},
print::Print,
wasm,
};
#[derive(Debug, Clone)]
pub struct BuiltContract {
pub name: String,
pub path: PathBuf,
}
#[derive(Parser, Debug, Clone)]
#[allow(clippy::struct_excessive_bools)]
pub struct Cmd {
#[arg(long)]
pub manifest_path: Option<std::path::PathBuf>,
#[arg(long)]
pub package: Option<String>,
#[arg(long, default_value = "release")]
pub profile: String,
#[arg(long, help_heading = "Features")]
pub features: Option<String>,
#[arg(
long,
conflicts_with = "features",
conflicts_with = "no_default_features",
help_heading = "Features"
)]
pub all_features: bool,
#[arg(long, help_heading = "Features")]
pub no_default_features: bool,
#[arg(long)]
pub out_dir: Option<std::path::PathBuf>,
#[arg(long)]
pub locked: bool,
#[arg(long, conflicts_with = "out_dir", help_heading = "Other")]
pub print_commands_only: bool,
#[command(flatten)]
pub build_args: BuildArgs,
}
#[derive(Parser, Debug, Clone, Default)]
pub struct BuildArgs {
#[arg(long, num_args=1, value_parser=parse_meta_arg, action=clap::ArgAction::Append, help_heading = "Metadata")]
pub meta: Vec<(String, String)>,
#[cfg_attr(feature = "additional-libs", arg(long))]
#[cfg_attr(not(feature = "additional-libs"), arg(long, hide = true))]
pub optimize: bool,
}
pub fn parse_meta_arg(s: &str) -> Result<(String, String), Error> {
let parts = s.splitn(2, '=');
let (key, value) = parts
.map(str::trim)
.next_tuple()
.ok_or_else(|| Error::MetaArg("must be in the form 'key=value'".to_string()))?;
Ok((key.to_string(), value.to_string()))
}
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error(transparent)]
Metadata(#[from] cargo_metadata::Error),
#[error(transparent)]
CargoCmd(io::Error),
#[error("exit status {0}")]
Exit(ExitStatus),
#[error("package {package} not found")]
PackageNotFound { package: String },
#[error("finding absolute path of Cargo.toml: {0}")]
AbsolutePath(io::Error),
#[error("creating out directory: {0}")]
CreatingOutDir(io::Error),
#[error("deleting existing artifact: {0}")]
DeletingArtifact(io::Error),
#[error("copying wasm file: {0}")]
CopyingWasmFile(io::Error),
#[error("getting the current directory: {0}")]
GettingCurrentDir(io::Error),
#[error("retrieving CARGO_HOME: {0}")]
CargoHome(io::Error),
#[error("reading wasm file: {0}")]
ReadingWasmFile(io::Error),
#[error("writing wasm file: {0}")]
WritingWasmFile(io::Error),
#[error("invalid meta entry: {0}")]
MetaArg(String),
#[error(
"use a rust version other than 1.81, 1.82, 1.83 or 1.91.0 to build contracts (got {0})"
)]
RustVersion(String),
#[error("must install with \"additional-libs\" feature.")]
OptimizeFeatureNotEnabled,
#[error("invalid Cargo.toml configuration: {0}")]
CargoConfiguration(String),
#[error(transparent)]
Xdr(#[from] stellar_xdr::curr::Error),
#[cfg(feature = "additional-libs")]
#[error(transparent)]
Optimize(#[from] optimize::Error),
#[error(transparent)]
Wasm(#[from] wasm::Error),
#[error(transparent)]
SpecTools(#[from] soroban_spec_tools::contract::Error),
#[error("wasm parsing error: {0}")]
WasmParsing(String),
}
const WASM_TARGET: &str = "wasm32v1-none";
const WASM_TARGET_OLD: &str = "wasm32-unknown-unknown";
const META_CUSTOM_SECTION_NAME: &str = "contractmetav0";
impl Default for Cmd {
fn default() -> Self {
Self {
manifest_path: None,
package: None,
profile: "release".to_string(),
features: None,
all_features: false,
no_default_features: false,
out_dir: None,
locked: false,
print_commands_only: false,
build_args: BuildArgs::default(),
}
}
}
impl Cmd {
#[allow(clippy::too_many_lines)]
pub fn run(&self, global_args: &global::Args) -> Result<Vec<BuiltContract>, Error> {
let print = Print::new(global_args.quiet);
let working_dir = env::current_dir().map_err(Error::GettingCurrentDir)?;
let metadata = self.metadata()?;
let packages = self.packages(&metadata)?;
let target_dir = &metadata.target_directory;
if !self.print_commands_only {
run_checks(metadata.workspace_root.as_std_path(), &self.profile)?;
}
if let Some(package) = &self.package {
if packages.is_empty() {
return Err(Error::PackageNotFound {
package: package.clone(),
});
}
}
let wasm_target = get_wasm_target()?;
let mut built_contracts = Vec::new();
for p in packages {
let mut cmd = Command::new("cargo");
cmd.stdout(Stdio::piped());
cmd.arg("rustc");
if self.locked {
cmd.arg("--locked");
}
let manifest_path = pathdiff::diff_paths(&p.manifest_path, &working_dir)
.unwrap_or(p.manifest_path.clone().into());
cmd.arg(format!(
"--manifest-path={}",
manifest_path.to_string_lossy()
));
cmd.arg("--crate-type=cdylib");
cmd.arg(format!("--target={wasm_target}"));
if self.profile == "release" {
cmd.arg("--release");
} else {
cmd.arg(format!("--profile={}", self.profile));
}
if self.all_features {
cmd.arg("--all-features");
}
if self.no_default_features {
cmd.arg("--no-default-features");
}
if let Some(features) = self.features() {
let requested: HashSet<String> = features.iter().cloned().collect();
let available = p.features.iter().map(|f| f.0).cloned().collect();
let activate = requested.intersection(&available).join(",");
if !activate.is_empty() {
cmd.arg(format!("--features={activate}"));
}
}
if let Some(rustflags) = make_rustflags_to_remap_absolute_paths(&print)? {
cmd.env("CARGO_BUILD_RUSTFLAGS", rustflags);
}
cmd.env("SOROBAN_SDK_BUILD_SYSTEM_SUPPORTS_SPEC_SHAKING_V2", "1");
let mut cmd_str_parts = Vec::<String>::new();
cmd_str_parts.extend(cmd.get_envs().map(|(key, val)| {
format!(
"{}={}",
key.to_string_lossy(),
shell_escape::escape(val.unwrap_or_default().to_string_lossy())
)
}));
cmd_str_parts.push("cargo".to_string());
cmd_str_parts.extend(
cmd.get_args()
.map(OsStr::to_string_lossy)
.map(Cow::into_owned),
);
let cmd_str = cmd_str_parts.join(" ");
if self.print_commands_only {
println!("{cmd_str}");
} else {
print.infoln(cmd_str);
let status = cmd.status().map_err(Error::CargoCmd)?;
if !status.success() {
return Err(Error::Exit(status));
}
let wasm_name = p.name.replace('-', "_");
let file = format!("{wasm_name}.wasm");
let target_file_path = Path::new(target_dir)
.join(&wasm_target)
.join(&self.profile)
.join(&file);
self.inject_meta(&target_file_path)?;
Self::filter_spec(&target_file_path)?;
let final_path = if let Some(out_dir) = &self.out_dir {
fs::create_dir_all(out_dir).map_err(Error::CreatingOutDir)?;
let out_file_path = Path::new(out_dir).join(&file);
fs::copy(target_file_path, &out_file_path).map_err(Error::CopyingWasmFile)?;
out_file_path
} else {
target_file_path
};
let wasm_bytes = fs::read(&final_path).map_err(Error::ReadingWasmFile)?;
#[cfg_attr(not(feature = "additional-libs"), allow(unused_mut))]
let mut optimized_wasm_bytes: Vec<u8> = Vec::new();
#[cfg(feature = "additional-libs")]
if self.build_args.optimize {
let mut path = final_path.clone();
path.set_extension("optimized.wasm");
optimize::optimize(true, vec![final_path.clone()], Some(path.clone()))?;
optimized_wasm_bytes = fs::read(&path).map_err(Error::ReadingWasmFile)?;
fs::remove_file(&final_path).map_err(Error::DeletingArtifact)?;
fs::rename(&path, &final_path).map_err(Error::CopyingWasmFile)?;
}
#[cfg(not(feature = "additional-libs"))]
if self.build_args.optimize {
return Err(Error::OptimizeFeatureNotEnabled);
}
Self::print_build_summary(
&print,
&p.name,
&final_path,
wasm_bytes,
optimized_wasm_bytes,
);
built_contracts.push(BuiltContract {
name: p.name.clone(),
path: final_path,
});
}
}
Ok(built_contracts)
}
fn features(&self) -> Option<Vec<String>> {
self.features
.as_ref()
.map(|f| f.split(&[',', ' ']).map(String::from).collect())
}
fn packages(&self, metadata: &Metadata) -> Result<Vec<Package>, Error> {
let name = if let Some(name) = self.package.clone() {
Some(name)
} else {
let manifest_path = path::absolute(
self.manifest_path
.clone()
.unwrap_or(PathBuf::from("Cargo.toml")),
)
.map_err(Error::AbsolutePath)?;
metadata
.packages
.iter()
.find(|p| p.manifest_path == manifest_path)
.map(|p| p.name.clone())
};
let packages = metadata
.packages
.iter()
.filter(|p|
if let Some(name) = &name {
&p.name == name
} else {
metadata.workspace_default_members.contains(&p.id)
&& p.targets
.iter()
.any(|t| t.crate_types.iter().any(|c| c == "cdylib"))
}
)
.cloned()
.collect();
Ok(packages)
}
fn metadata(&self) -> Result<Metadata, cargo_metadata::Error> {
let mut cmd = MetadataCommand::new();
cmd.no_deps();
if let Some(manifest_path) = &self.manifest_path {
cmd.manifest_path(manifest_path);
}
cmd.exec()
}
fn inject_meta(&self, target_file_path: &PathBuf) -> Result<(), Error> {
let mut wasm_bytes = fs::read(target_file_path).map_err(Error::ReadingWasmFile)?;
let xdr = self.encoded_new_meta()?;
wasm_gen::write_custom_section(&mut wasm_bytes, META_CUSTOM_SECTION_NAME, &xdr);
fs::remove_file(target_file_path).map_err(Error::DeletingArtifact)?;
fs::write(target_file_path, wasm_bytes).map_err(Error::WritingWasmFile)
}
fn filter_spec(target_file_path: &PathBuf) -> Result<(), Error> {
use soroban_spec_tools::contract::Spec;
use soroban_spec_tools::wasm::replace_custom_section;
let wasm_bytes = fs::read(target_file_path).map_err(Error::ReadingWasmFile)?;
let spec = Spec::new(&wasm_bytes)?;
if soroban_spec::shaking::spec_shaking_version_for_meta(&spec.meta) != 2 {
return Ok(());
}
let markers = soroban_spec::shaking::find_all(&wasm_bytes);
let filtered_xdr = filter_and_dedup_spec(spec.spec.clone(), &markers)?;
let new_wasm = replace_custom_section(&wasm_bytes, "contractspecv0", &filtered_xdr)
.map_err(|e| Error::WasmParsing(e.to_string()))?;
fs::remove_file(target_file_path).map_err(Error::DeletingArtifact)?;
fs::write(target_file_path, new_wasm).map_err(Error::WritingWasmFile)
}
fn encoded_new_meta(&self) -> Result<Vec<u8>, Error> {
let mut new_meta: Vec<ScMetaEntry> = Vec::new();
let cli_meta_entry = ScMetaEntry::ScMetaV0(ScMetaV0 {
key: "cliver".to_string().try_into().unwrap(),
val: version::one_line().clone().try_into().unwrap(),
});
new_meta.push(cli_meta_entry);
for (k, v) in self.build_args.meta.clone() {
let key: StringM = k
.clone()
.try_into()
.map_err(|e| Error::MetaArg(format!("{k} is an invalid metadata key: {e}")))?;
let val: StringM = v
.clone()
.try_into()
.map_err(|e| Error::MetaArg(format!("{v} is an invalid metadata value: {e}")))?;
let meta_entry = ScMetaEntry::ScMetaV0(ScMetaV0 { key, val });
new_meta.push(meta_entry);
}
let mut buffer = Vec::new();
let mut writer = Limited::new(Cursor::new(&mut buffer), Limits::none());
for entry in new_meta {
entry.write_xdr(&mut writer)?;
}
Ok(buffer)
}
fn print_build_summary(
print: &Print,
name: &str,
path: &Path,
wasm_bytes: Vec<u8>,
optimized_wasm_bytes: Vec<u8>,
) {
print.infoln("Build Summary:");
let rel_path = path
.strip_prefix(env::current_dir().unwrap())
.unwrap_or(path);
let size = wasm_bytes.len();
let optimized_size = optimized_wasm_bytes.len();
let size_description = if optimized_size > 0 {
format!("{optimized_size} bytes optimized (original size was {size} bytes)")
} else {
format!("{size} bytes")
};
let bytes = if optimized_size > 0 {
&optimized_wasm_bytes
} else {
&wasm_bytes
};
print.blankln(format!(
"Wasm File: {path} ({size_description})",
path = rel_path.display()
));
print.blankln(format!("Wasm Hash: {}", hex::encode(Sha256::digest(bytes))));
print.blankln(format!("Wasm Size: {size_description}"));
let parser = wasmparser::Parser::new(0);
let export_names: Vec<&str> = parser
.parse_all(&wasm_bytes)
.filter_map(Result::ok)
.filter_map(|payload| {
if let wasmparser::Payload::ExportSection(exports) = payload {
Some(exports)
} else {
None
}
})
.flatten()
.filter_map(Result::ok)
.filter(|export| matches!(export.kind, wasmparser::ExternalKind::Func))
.map(|export| export.name)
.sorted()
.collect();
if export_names.is_empty() {
print.blankln("Exported Functions: None found");
} else {
print.blankln(format!("Exported Functions: {} found", export_names.len()));
for name in export_names {
print.blankln(format!(" • {name}"));
}
}
if let Ok(spec) = soroban_spec_tools::Spec::from_wasm(bytes) {
for w in spec.verify() {
print.warnln(format!("{name}: {w}"));
}
}
print.checkln("Build Complete\n");
}
}
fn make_rustflags_to_remap_absolute_paths(print: &Print) -> Result<Option<String>, Error> {
let cargo_home = home::cargo_home().map_err(Error::CargoHome)?;
if format!("{}", cargo_home.display())
.find(|c: char| c.is_whitespace())
.is_some()
{
print.warnln("Cargo home directory contains whitespace. Dependency paths will not be remapped; builds may not be reproducible.");
return Ok(None);
}
if env::var("RUSTFLAGS").is_ok() {
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.");
return Ok(None);
}
if env::var("CARGO_ENCODED_RUSTFLAGS").is_ok() {
print.warnln("`CARGO_ENCODED_RUSTFLAGS` set. Dependency paths will not be remapped; builds may not be reproducible.");
return Ok(None);
}
let target = get_wasm_target()?;
let env_var_name = format!("TARGET_{target}_RUSTFLAGS");
if env::var(env_var_name.clone()).is_ok() {
print.warnln(format!("`{env_var_name}` set. Dependency paths will not be remapped; builds may not be reproducible."));
return Ok(None);
}
let registry_prefix = cargo_home.join("registry").join("src");
let registry_prefix_str = registry_prefix.display().to_string();
#[cfg(windows)]
let registry_prefix_str = registry_prefix_str.replace('\\', "/");
let new_rustflag = format!("--remap-path-prefix={registry_prefix_str}=");
let mut rustflags = get_rustflags().unwrap_or_default();
rustflags.push(new_rustflag);
let rustflags = rustflags.join(" ");
Ok(Some(rustflags))
}
fn get_rustflags() -> Option<Vec<String>> {
if let Ok(a) = env::var("CARGO_BUILD_RUSTFLAGS") {
let args = a
.split_whitespace()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
return Some(args.collect());
}
None
}
fn get_wasm_target() -> Result<String, Error> {
let Ok(current_version) = version() else {
return Ok(WASM_TARGET.into());
};
let v184 = Version::parse("1.84.0").unwrap();
let v182 = Version::parse("1.82.0").unwrap();
let v191 = Version::parse("1.91.0").unwrap();
if current_version == v191 {
return Err(Error::RustVersion(current_version.to_string()));
}
if current_version >= v182 && current_version < v184 {
return Err(Error::RustVersion(current_version.to_string()));
}
if current_version < v184 {
Ok(WASM_TARGET_OLD.into())
} else {
Ok(WASM_TARGET.into())
}
}
fn run_checks(workspace_root: &Path, profile: &str) -> Result<(), Error> {
let cargo_toml_path = workspace_root.join("Cargo.toml");
let cargo_toml_str = match fs::read_to_string(&cargo_toml_path) {
Ok(s) => s,
Err(e) => {
return Err(Error::CargoConfiguration(format!(
"Could not read Cargo.toml: {e}"
)));
}
};
let doc: toml_edit::DocumentMut = match cargo_toml_str.parse() {
Ok(d) => d,
Err(e) => {
return Err(Error::CargoConfiguration(format!(
"Could not parse Cargo.toml to run checks: {e}"
)));
}
};
check_overflow_checks(&doc, profile)?;
Ok(())
}
fn check_overflow_checks(doc: &toml_edit::DocumentMut, profile: &str) -> Result<(), Error> {
fn get_overflow_checks(
doc: &toml_edit::DocumentMut,
profile: &str,
visited: &mut Vec<String>,
) -> Option<bool> {
if visited.contains(&profile.to_string()) {
return None; }
visited.push(profile.to_string());
let profile_section = doc.get("profile")?.get(profile)?;
if let Some(val) = profile_section
.get("overflow-checks")
.and_then(toml_edit::Item::as_bool)
{
return Some(val);
}
if let Some(inherits) = profile_section.get("inherits").and_then(|v| v.as_str()) {
return get_overflow_checks(doc, inherits, visited);
}
None
}
let mut visited = Vec::new();
if get_overflow_checks(doc, profile, &mut visited) == Some(true) {
Ok(())
} else {
Err(Error::CargoConfiguration(format!(
"`overflow-checks` is not enabled for profile `{profile}`. \
To prevent silent integer overflow, add `overflow-checks = true` to \
[profile.{profile}] in your Cargo.toml."
)))
}
}
#[allow(clippy::implicit_hasher)]
pub fn filter_and_dedup_spec(
entries: Vec<stellar_xdr::curr::ScSpecEntry>,
markers: &HashSet<soroban_spec::shaking::Marker>,
) -> Result<Vec<u8>, Error> {
let mut seen = HashSet::new();
let mut filtered_xdr = Vec::new();
let mut writer = Limited::new(Cursor::new(&mut filtered_xdr), Limits::none());
for entry in soroban_spec::shaking::filter(entries, markers) {
let entry_xdr = entry.to_xdr(Limits::none())?;
if seen.insert(entry_xdr) {
entry.write_xdr(&mut writer)?;
}
}
Ok(filtered_xdr)
}