use crate::cli::commands::template::publish::TemplatePublishArgs;
use crate::cli::config::Config;
use crate::cli::util;
use crate::{loading, project};
use anyhow::{Context, anyhow};
use cargo_toml::Manifest;
use clap::Parser;
use ootle_network::Network;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use tari_ootle_publish_lib::walletd_client::ComponentAddressOrName;
use tari_ootle_template_metadata::TemplateMetadata;
use tari_utilities::Hidden;
use tokio::fs;
use tokio::process::Command;
#[derive(Clone, Parser, Debug)]
pub struct PublishArgs {
#[arg(default_value = ".")]
pub path: PathBuf,
#[arg(short = 'a', long)]
pub account: Option<ComponentAddressOrName>,
#[arg(short = 'c', long)]
pub custom_network: Option<String>,
#[arg(short = 'y', long, default_value_t = false)]
pub yes: bool,
#[arg(short = 'f', long)]
pub max_fee: Option<u64>,
#[arg(long, alias = "bin")]
pub binary: Option<PathBuf>,
#[arg(long, env = "TARI_WALLET_DAEMON_URL")]
pub wallet_daemon_url: Option<url::Url>,
#[arg(long, default_value_t = false)]
pub publish_metadata: bool,
#[arg(long)]
pub metadata_server_url: Option<url::Url>,
#[arg(long, default_value_t = false)]
pub no_cargo_opts: bool,
}
const CARGO_OPT_CONFIGS: &[&str] = &[
"profile.release.opt-level='s'", "profile.release.lto=true", "profile.release.codegen-units=1", "profile.release.panic='abort'", "profile.release.strip=true", ];
pub async fn build_template(crate_dir: &Path, optimize: bool) -> anyhow::Result<PathBuf> {
let cargo_path = crate_dir.join("Cargo.toml");
if !cargo_path.exists() {
return Err(anyhow!("No Cargo.toml found at {}", cargo_path.display()));
}
let manifest = Manifest::from_path(&cargo_path)?;
let crate_name = manifest
.package
.ok_or_else(|| anyhow!("No [package] section in {}", cargo_path.display()))?
.name;
let template_bin = loading!(
format!("Building WASM template project **{}**", crate_name),
build_project(crate_dir, &crate_name, optimize).await
)?;
Ok(template_bin)
}
pub async fn handle(
config: Config,
network_override: Option<Network>,
api_key: Option<Hidden<String>>,
args: PublishArgs,
) -> anyhow::Result<()> {
let template_args = TemplatePublishArgs {
path: args.path,
account: args.account,
custom_network: args.custom_network,
yes: args.yes,
max_fee: args.max_fee,
binary: args.binary,
wallet_daemon_url: args.wallet_daemon_url,
publish_metadata: args.publish_metadata,
metadata_server_url: args.metadata_server_url,
no_cargo_opts: args.no_cargo_opts,
};
crate::cli::commands::template::publish::handle(config, network_override, api_key, template_args).await
}
const WALLET_DAEMON_API_KEY_ENV: &str = "TARI_WALLET_DAEMON_API_KEY";
async fn build_project(dir: &Path, name: &str, optimize: bool) -> anyhow::Result<PathBuf> {
let mut cmd = Command::new("cargo");
cmd.arg("build").arg("--target=wasm32-unknown-unknown").arg("--release");
if optimize {
for config in CARGO_OPT_CONFIGS {
cmd.arg("--config").arg(config);
}
}
cmd.env_remove(WALLET_DAEMON_API_KEY_ENV)
.current_dir(dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let process = cmd.spawn()?;
let output = process.wait_with_output().await?;
if !output.status.success() {
return Err(anyhow!(
"Failed to build project: {dir:?}\nBuild Output:\n\n{}",
String::from_utf8_lossy(&output.stderr)
));
}
let target_dir = find_target_dir(dir).await?;
let wasm_name = name.replace('-', "_");
let output_bin = target_dir
.join("wasm32-unknown-unknown")
.join("release")
.join(format!("{wasm_name}.wasm"));
if !util::file_exists(&output_bin).await? {
return Err(anyhow!(
"Binary is not present after build at {:?}\n\nBuild Output:\n{}",
output_bin,
String::from_utf8_lossy(&output.stderr)
));
}
Ok(output_bin)
}
pub async fn find_target_dir(dir: &Path) -> anyhow::Result<PathBuf> {
let output = Command::new("cargo")
.args(["metadata", "--format-version=1", "--no-deps"])
.current_dir(dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await?;
if !output.status.success() {
return Err(anyhow!(
"Failed to get cargo metadata: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let metadata: serde_json::Value = serde_json::from_slice(&output.stdout).context("parsing cargo metadata")?;
metadata["target_directory"]
.as_str()
.map(PathBuf::from)
.ok_or_else(|| anyhow!("cargo metadata missing target_directory"))
}
const METADATA_CBOR_FILENAME: &str = "template_metadata.cbor";
pub async fn find_metadata_cbor(project_dir: &Path) -> anyhow::Result<PathBuf> {
let target_dir = find_target_dir(project_dir).await?;
let build_dir = target_dir.join("wasm32-unknown-unknown").join("release").join("build");
if !build_dir.exists() {
return Err(anyhow!(
"Build output directory not found at {}. Run `tari build` first.",
build_dir.display()
));
}
let mut newest: Option<(PathBuf, std::time::SystemTime)> = None;
for entry in std::fs::read_dir(&build_dir).context("reading build directory")? {
let entry = entry?;
let out_file = entry.path().join("out").join(METADATA_CBOR_FILENAME);
if out_file.exists() {
let modified = std::fs::metadata(&out_file)
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
if newest.as_ref().is_none_or(|(_, t)| modified > *t) {
newest = Some((out_file, modified));
}
}
}
newest.map(|(path, _)| path).ok_or_else(|| {
anyhow!(
"No {METADATA_CBOR_FILENAME} found in build output. \
Make sure the template uses tari_ootle_template_build in build.rs \
and has been built with `tari build`."
)
})
}
pub fn decode_metadata_cbor(bytes: &[u8]) -> anyhow::Result<TemplateMetadata> {
TemplateMetadata::from_cbor(bytes).map_err(|e| {
anyhow!(
"decoding metadata CBOR: {e}\n\
\n\
The metadata file appears to be in an older format. Bump `tari_ootle_template_build` \
in your template's [build-dependencies] to ^0.7 and run `tari build` again."
)
})
}
pub async fn load_project_config(project_folder: &Path) -> anyhow::Result<project::ProjectConfig> {
let mut search_dir = project_folder.to_path_buf();
loop {
let config_file = search_dir.join(project::CONFIG_FILE_NAME);
if config_file.exists() {
let content = fs::read_to_string(&config_file).await.map_err(|error| {
anyhow!(
"Failed to load project config file (at {}): {}",
config_file.display(),
error
)
})?;
let config = toml::from_str::<project::ProjectConfig>(content.as_str()).context("parsing config toml")?;
if config.has_ignored_wallet_daemon_url() {
println!(
"⚠️ Ignoring wallet-daemon-url in {}: it is no longer read from the project config. \
Use --wallet-daemon-url, TARI_WALLET_DAEMON_URL, or the global CLI config.",
config_file.display()
);
}
return Ok(config);
}
if !search_dir.pop() {
break;
}
}
Ok(project::ProjectConfig::default())
}
pub fn resolve_active_network(
cli_override: Option<Network>,
project: &project::ProjectConfig,
global: &Config,
) -> Network {
cli_override
.or_else(|| project.default_network())
.or(global.default_network)
.unwrap_or_default()
}
pub fn resolve_wallet_daemon_url(cli_override: Option<&url::Url>, global: &Config, network: Network) -> url::Url {
cli_override
.or_else(|| global.wallet_daemon_url(network))
.cloned()
.unwrap_or_else(|| {
url::Url::parse(project::DEFAULT_WALLET_DAEMON_URL).expect("default wallet daemon URL is valid")
})
}
pub fn is_loopback_url(url: &url::Url) -> bool {
match url.host() {
Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
None => false,
}
}
pub fn ensure_wallet_daemon_url_allowed(url: &url::Url, has_api_key: bool) -> anyhow::Result<()> {
if has_api_key && url.scheme() == "http" && !is_loopback_url(url) {
return Err(anyhow!(
"Refusing to send the wallet daemon API key over plain http to non-loopback host {url}. \
Use https, or a loopback address."
));
}
Ok(())
}
#[cfg(test)]
mod wallet_daemon_url_tests {
use super::*;
#[test]
fn project_url_is_ignored() {
let project: project::ProjectConfig =
toml::from_str("[networks.esmeralda]\nwallet-daemon-url = \"https://attacker.example/json_rpc\"\n")
.unwrap();
assert!(project.has_ignored_wallet_daemon_url());
let url = resolve_wallet_daemon_url(None, &Config::default(), Network::Esmeralda);
assert_eq!(url.as_str(), project::DEFAULT_WALLET_DAEMON_URL);
}
#[test]
fn cli_url_overrides_global() {
let url = url::Url::parse("https://wallet.example/json_rpc").unwrap();
assert_eq!(
resolve_wallet_daemon_url(Some(&url), &Config::default(), Network::Esmeralda),
url
);
}
#[test]
fn api_key_over_plain_http_to_remote_host_is_refused() {
let url = url::Url::parse("http://wallet.example/json_rpc").unwrap();
assert!(ensure_wallet_daemon_url_allowed(&url, true).is_err());
assert!(ensure_wallet_daemon_url_allowed(&url, false).is_ok());
let local = url::Url::parse("http://127.0.0.1:5100/json_rpc").unwrap();
assert!(ensure_wallet_daemon_url_allowed(&local, true).is_ok());
}
#[test]
fn loopback_detection() {
for u in ["http://localhost:1/", "http://127.0.0.2/", "http://[::1]/"] {
assert!(is_loopback_url(&url::Url::parse(u).unwrap()), "{u}");
}
for u in ["http://10.0.0.1/", "http://localhost.attacker.example/"] {
assert!(!is_loopback_url(&url::Url::parse(u).unwrap()), "{u}");
}
}
}