use std::path::{Path, PathBuf};
use std::process::Command;
use ed25519_dalek::{Signer, SigningKey};
use cloacina_constructor_contract::{PrimitiveKind, ProviderManifest};
pub use cloacina_constructor_contract::ProviderRuntime;
pub const PROVIDER_MANIFEST_FILE: &str = "provider.json";
pub const PROVIDER_EXTENSION: &str = "cloacina";
#[derive(Debug, Clone)]
pub struct ProviderPackageOptions {
pub crate_dir: PathBuf,
pub output: Option<PathBuf>,
pub sign_key: Option<PathBuf>,
pub manifest_bin: String,
pub release: bool,
pub runtime: ProviderRuntime,
}
impl ProviderPackageOptions {
pub fn new(crate_dir: impl Into<PathBuf>) -> Self {
Self {
crate_dir: crate_dir.into(),
output: None,
sign_key: None,
manifest_bin: "emit_manifest".to_string(),
release: true,
runtime: ProviderRuntime::Wasm,
}
}
pub fn new_native(crate_dir: impl Into<PathBuf>) -> Self {
Self {
runtime: ProviderRuntime::Native,
..Self::new(crate_dir)
}
}
}
#[derive(Debug, Clone)]
pub struct ProviderPackageResult {
pub archive: PathBuf,
pub signed: bool,
pub provider_name: String,
pub provider_version: String,
pub constructors: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum ProviderPackageError {
#[error("{0}")]
Io(String),
#[error("build step failed: {0}")]
Build(String),
#[error("provider manifest parse failed: {0}")]
Manifest(String),
#[error("signing key error: {0}")]
SigningKey(String),
#[error("pack failed: {0}")]
Pack(String),
}
pub fn package_constructor_provider(
opts: &ProviderPackageOptions,
) -> Result<ProviderPackageResult, ProviderPackageError> {
let crate_dir = &opts.crate_dir;
if !crate_dir.join("Cargo.toml").exists() {
return Err(ProviderPackageError::Io(format!(
"no Cargo.toml in constructor provider crate dir {}",
crate_dir.display()
)));
}
let component = match opts.runtime {
ProviderRuntime::Wasm => build_wasm_component(crate_dir, opts.release)?,
ProviderRuntime::Native => build_native_cdylib(crate_dir, opts.release)?,
};
let manifest_json = emit_manifest_json(crate_dir, &opts.manifest_bin, opts.runtime)?;
let mut provider = ProviderManifest::from_json(&manifest_json)
.map_err(|e| ProviderPackageError::Manifest(e.to_string()))?;
let head = provider
.constructors
.first()
.ok_or_else(|| {
ProviderPackageError::Manifest(format!(
"provider '{}' declares no constructors",
provider.name
))
})?
.clone();
let staging = tempfile::TempDir::new()
.map_err(|e| ProviderPackageError::Io(format!("create staging dir: {e}")))?;
let pkg_dir = staging.path();
let component_file = component
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "provider.wasm".to_string());
std::fs::copy(&component, pkg_dir.join(&component_file))
.map_err(|e| ProviderPackageError::Io(format!("copy provider component: {e}")))?;
provider.component = component_file.clone();
provider.runtime = opts.runtime;
let provider_name = provider.name.clone();
let constructor_names: Vec<String> = provider
.constructors
.iter()
.map(|c| c.name.clone())
.collect();
let provider_json = provider
.to_json()
.map_err(|e| ProviderPackageError::Manifest(format!("serialize provider.json: {e}")))?;
std::fs::write(pkg_dir.join(PROVIDER_MANIFEST_FILE), provider_json)
.map_err(|e| ProviderPackageError::Io(format!("write provider.json: {e}")))?;
let package_toml = render_package_toml(
&provider_name,
&provider.version,
&head.interface,
head.interface_version,
head.primitive_kind,
&component_file,
opts.runtime,
);
std::fs::write(pkg_dir.join("package.toml"), package_toml)
.map_err(|e| ProviderPackageError::Io(format!("write package.toml: {e}")))?;
let signed = if let Some(key_path) = &opts.sign_key {
sign_package_dir(pkg_dir, key_path)?;
true
} else {
false
};
let result = fidius_core::package::pack_package(pkg_dir, opts.output.as_deref())
.map_err(|e| ProviderPackageError::Pack(e.to_string()))?;
Ok(ProviderPackageResult {
archive: result.path,
signed,
provider_name,
provider_version: provider.version.clone(),
constructors: constructor_names,
})
}
#[allow(clippy::too_many_arguments)]
fn render_package_toml(
name: &str,
version: &str,
interface: &str,
interface_version: u32,
primitive: PrimitiveKind,
component_file: &str,
runtime: ProviderRuntime,
) -> String {
let header = format!(
"# Generated by cloacina constructor packaging (CLOACI-T-0827).\n\
[package]\n\
name = \"{name}\"\n\
version = \"{version}\"\n\
interface = \"{interface}\"\n\
interface_version = {iface_version}\n\
extension = \"{ext}\"\n\
runtime = \"{fidius_runtime}\"\n\n\
[metadata]\n\
category = \"constructor\"\n\
primitive_kind = \"{primitive}\"\n",
name = name,
version = version,
interface = interface,
iface_version = interface_version,
ext = PROVIDER_EXTENSION,
fidius_runtime = fidius_runtime_str(runtime),
primitive = primitive_kind_str(primitive),
);
match runtime {
ProviderRuntime::Wasm => {
format!("{header}\n[wasm]\ncomponent = \"{component_file}\"\n")
}
ProviderRuntime::Native => header,
}
}
fn fidius_runtime_str(runtime: ProviderRuntime) -> &'static str {
match runtime {
ProviderRuntime::Wasm => "wasm",
ProviderRuntime::Native => "rust",
}
}
fn primitive_kind_str(kind: PrimitiveKind) -> &'static str {
match kind {
PrimitiveKind::Task => "task",
PrimitiveKind::Trigger => "trigger",
PrimitiveKind::Accumulator => "accumulator",
PrimitiveKind::Reactor => "reactor",
}
}
fn build_wasm_component(crate_dir: &Path, release: bool) -> Result<PathBuf, ProviderPackageError> {
let mut cmd = Command::new("cargo");
cmd.arg("build")
.arg("--lib")
.arg("--target")
.arg("wasm32-wasip2")
.current_dir(crate_dir);
let profile = if release {
cmd.arg("--release");
"release"
} else {
"debug"
};
let status = cmd
.status()
.map_err(|e| ProviderPackageError::Build(format!("spawn cargo build: {e}")))?;
if !status.success() {
return Err(ProviderPackageError::Build(format!(
"cargo build --target wasm32-wasip2 failed with status {status}"
)));
}
let target_root = match std::env::var_os("CARGO_TARGET_DIR") {
Some(dir) if !dir.is_empty() => {
let p = PathBuf::from(dir);
if p.is_absolute() {
p
} else {
crate_dir.join(p)
}
}
_ => crate_dir.join("target"),
};
let out_dir = target_root.join("wasm32-wasip2").join(profile);
let preferred = crate_name(crate_dir).map(|n| out_dir.join(format!("{n}.wasm")));
if let Some(p) = &preferred {
if p.exists() {
return Ok(p.clone());
}
}
let mut wasms: Vec<PathBuf> = std::fs::read_dir(&out_dir)
.map_err(|e| ProviderPackageError::Io(format!("read {}: {e}", out_dir.display())))?
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("wasm"))
.collect();
wasms.sort();
match wasms.len() {
0 => Err(ProviderPackageError::Build(format!(
"build succeeded but no .wasm component found in {}",
out_dir.display()
))),
1 => Ok(wasms.pop().unwrap()),
_ => Err(ProviderPackageError::Build(format!(
"multiple .wasm components in {} ({:?}); name the lib to match the crate",
out_dir.display(),
wasms
))),
}
}
fn build_native_cdylib(crate_dir: &Path, release: bool) -> Result<PathBuf, ProviderPackageError> {
let mut cmd = Command::new("cargo");
cmd.arg("build")
.arg("--lib")
.args(["--features", "native"])
.current_dir(crate_dir);
let profile = if release {
cmd.arg("--release");
"release"
} else {
"debug"
};
let status = cmd
.status()
.map_err(|e| ProviderPackageError::Build(format!("spawn cargo build (native): {e}")))?;
if !status.success() {
return Err(ProviderPackageError::Build(format!(
"cargo build --lib --features native failed with status {status}"
)));
}
let target_root = match std::env::var_os("CARGO_TARGET_DIR") {
Some(dir) if !dir.is_empty() => {
let p = PathBuf::from(dir);
if p.is_absolute() {
p
} else {
crate_dir.join(p)
}
}
_ => crate_dir.join("target"),
};
let out_dir = target_root.join(profile);
if let Some(stem) = crate_name(crate_dir) {
for candidate in [
format!("lib{stem}.dylib"), format!("lib{stem}.so"), format!("{stem}.dll"), ] {
let p = out_dir.join(&candidate);
if p.exists() {
return Ok(p);
}
}
}
let mut libs: Vec<PathBuf> = std::fs::read_dir(&out_dir)
.map_err(|e| ProviderPackageError::Io(format!("read {}: {e}", out_dir.display())))?
.flatten()
.map(|e| e.path())
.filter(|p| {
matches!(
p.extension().and_then(|x| x.to_str()),
Some("dylib") | Some("so") | Some("dll")
)
})
.collect();
libs.sort();
match libs.len() {
0 => Err(ProviderPackageError::Build(format!(
"build succeeded but no cdylib found in {}",
out_dir.display()
))),
1 => Ok(libs.pop().unwrap()),
_ => Err(ProviderPackageError::Build(format!(
"multiple cdylibs in {} ({:?}); name the lib to match the crate",
out_dir.display(),
libs
))),
}
}
fn crate_name(crate_dir: &Path) -> Option<String> {
let toml = std::fs::read_to_string(crate_dir.join("Cargo.toml")).ok()?;
let value: toml::Value = toml.parse().ok()?;
let name = value.get("package")?.get("name")?.as_str()?;
Some(name.replace('-', "_"))
}
fn emit_manifest_json(
crate_dir: &Path,
manifest_bin: &str,
runtime: ProviderRuntime,
) -> Result<String, ProviderPackageError> {
let mut cmd = Command::new("cargo");
cmd.args(["run", "--quiet", "--bin", manifest_bin]);
if runtime == ProviderRuntime::Native {
cmd.args(["--features", "native"]);
}
let out = cmd.current_dir(crate_dir).output().map_err(|e| {
ProviderPackageError::Build(format!("spawn cargo run --bin {manifest_bin}: {e}"))
})?;
if !out.status.success() {
return Err(ProviderPackageError::Build(format!(
"`cargo run --bin {manifest_bin}` failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
String::from_utf8(out.stdout)
.map_err(|e| ProviderPackageError::Manifest(format!("manifest stdout not UTF-8: {e}")))
}
fn sign_package_dir(pkg_dir: &Path, key_path: &Path) -> Result<(), ProviderPackageError> {
let key_bytes: [u8; 32] = std::fs::read(key_path)
.map_err(|e| ProviderPackageError::SigningKey(format!("read {}: {e}", key_path.display())))?
.try_into()
.map_err(|_| {
ProviderPackageError::SigningKey("secret key must be exactly 32 bytes".to_string())
})?;
let signing_key = SigningKey::from_bytes(&key_bytes);
let digest = fidius_core::package::package_digest(pkg_dir)
.map_err(|e| ProviderPackageError::Pack(format!("compute package digest: {e}")))?;
let signature = signing_key.sign(&digest);
std::fs::write(pkg_dir.join("package.sig"), signature.to_bytes())
.map_err(|e| ProviderPackageError::Io(format!("write package.sig: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use cloacina_constructor_contract::ConstructorManifest;
fn member(name: &str) -> ConstructorManifest {
ConstructorManifest {
name: name.into(),
version: "0.1.0".into(),
primitive_kind: PrimitiveKind::Task,
interface: "task-constructor".into(),
interface_version: 1,
params: vec![],
config_fields: vec![],
dependencies: vec![],
description: None,
author: None,
}
}
#[test]
fn provider_manifest_round_trips() {
let p = ProviderManifest {
name: "cloacina-provider-fs".into(),
version: "0.1.0".into(),
component: "cloacina_provider_fs.wasm".into(),
runtime: Default::default(),
constructors: vec![member("read_file"), member("write_file")],
};
let s = p.to_json().unwrap();
let back = ProviderManifest::from_json(&s).unwrap();
assert_eq!(p, back);
assert_eq!(back.constructors.len(), 2);
assert_eq!(back.constructor("write_file").unwrap().name, "write_file");
}
#[test]
fn package_toml_has_wasm_runtime_and_component() {
let toml = render_package_toml(
"cloacina-provider-fs",
"0.1.0",
"task-constructor",
1,
PrimitiveKind::Task,
"cloacina_provider_fs.wasm",
ProviderRuntime::Wasm,
);
assert!(toml.contains("runtime = \"wasm\""));
assert!(toml.contains("component = \"cloacina_provider_fs.wasm\""));
assert!(toml.contains("interface = \"task-constructor\""));
assert!(toml.contains("name = \"cloacina-provider-fs\""));
assert!(toml.contains("extension = \"cloacina\""));
let _: toml::Value = toml.parse().expect("rendered package.toml is valid TOML");
}
#[test]
fn native_package_toml_uses_rust_runtime_and_no_wasm_section() {
let toml = render_package_toml(
"cloacina-provider-fs-native",
"0.1.0",
"task-constructor",
1,
PrimitiveKind::Task,
"libcloacina_provider_fs_native.dylib",
ProviderRuntime::Native,
);
assert!(toml.contains("runtime = \"rust\""));
assert!(
!toml.contains("[wasm]"),
"native package.toml: no [wasm] section"
);
assert!(
!toml.contains("runtime = \"native\""),
"not a fidius runtime value"
);
assert!(toml.contains("primitive_kind = \"task\""));
let _: toml::Value = toml
.parse()
.expect("rendered native package.toml is valid TOML");
}
}