use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageManifest<M> {
pub package: PackageHeader,
pub metadata: M,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageHeader {
pub name: String,
pub version: String,
pub interface: String,
pub interface_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extension: Option<String>,
}
impl PackageHeader {
pub fn extension(&self) -> &str {
self.extension.as_deref().unwrap_or("fid")
}
}
#[derive(Debug, thiserror::Error)]
pub enum PackageError {
#[error("package.toml not found in {path}")]
ManifestNotFound { path: String },
#[error("failed to parse package.toml: {0}")]
ParseError(#[from] toml::de::Error),
#[error("io error reading package.toml: {0}")]
Io(#[from] std::io::Error),
#[error("package build failed: {0}")]
BuildFailed(String),
#[error("package.sig not found in {path}")]
SignatureNotFound { path: String },
#[error("package signature invalid for {path}")]
SignatureInvalid { path: String },
#[error("archive error: {0}")]
ArchiveError(String),
#[error("invalid archive: {0}")]
InvalidArchive(String),
}
pub fn load_manifest<M: DeserializeOwned>(dir: &Path) -> Result<PackageManifest<M>, PackageError> {
let manifest_path = dir.join("package.toml");
if !manifest_path.exists() {
return Err(PackageError::ManifestNotFound {
path: dir.display().to_string(),
});
}
let content = std::fs::read_to_string(&manifest_path)?;
let manifest: PackageManifest<M> = toml::from_str(&content)?;
Ok(manifest)
}
pub fn load_manifest_untyped(dir: &Path) -> Result<PackageManifest<toml::Value>, PackageError> {
load_manifest::<toml::Value>(dir)
}
pub fn package_digest(dir: &Path) -> Result<[u8; 32], PackageError> {
use sha2::{Digest, Sha256};
let mut files = Vec::new();
collect_files(dir, dir, &mut files)?;
files.sort();
let mut hasher = Sha256::new();
for rel_path in &files {
let abs_path = dir.join(rel_path);
let contents = std::fs::read(&abs_path)?;
let path_bytes = rel_path.as_bytes();
hasher.update((path_bytes.len() as u64).to_le_bytes());
hasher.update(path_bytes);
hasher.update((contents.len() as u64).to_le_bytes());
hasher.update(&contents);
}
Ok(hasher.finalize().into())
}
fn collect_files(root: &Path, dir: &Path, out: &mut Vec<String>) -> Result<(), PackageError> {
let entries = std::fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if path.is_dir() {
if name_str == "target" || name_str == ".git" {
continue;
}
collect_files(root, &path, out)?;
continue;
}
if name_str.ends_with(".sig") {
continue;
}
let rel = path
.strip_prefix(root)
.expect("path is under root")
.to_string_lossy()
.replace('\\', "/");
out.push(rel);
}
Ok(())
}
fn collect_archive_files(
root: &Path,
dir: &Path,
out: &mut Vec<String>,
) -> Result<(), PackageError> {
let entries = std::fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if path.is_dir() {
if name_str == "target" || name_str == ".git" {
continue;
}
collect_archive_files(root, &path, out)?;
continue;
}
let rel = path
.strip_prefix(root)
.expect("path is under root")
.to_string_lossy()
.replace('\\', "/");
out.push(rel);
}
Ok(())
}
#[derive(Debug)]
pub struct PackResult {
pub path: PathBuf,
pub unsigned: bool,
}
pub fn pack_package(dir: &Path, output: Option<&Path>) -> Result<PackResult, PackageError> {
use bzip2::write::BzEncoder;
use bzip2::Compression;
let manifest = load_manifest_untyped(dir)?;
let pkg = &manifest.package;
let prefix = format!("{}-{}", pkg.name, pkg.version);
let ext = pkg.extension();
let unsigned = !dir.join("package.sig").exists();
let out_path = match output {
Some(p) => p.to_path_buf(),
None => PathBuf::from(format!("{prefix}.{ext}")),
};
let file = std::fs::File::create(&out_path).map_err(|e| {
PackageError::ArchiveError(format!("failed to create {}: {e}", out_path.display()))
})?;
let encoder = BzEncoder::new(file, Compression::best());
let mut tar = tar::Builder::new(encoder);
let mut files = Vec::new();
collect_archive_files(dir, dir, &mut files)?;
files.sort();
for rel_path in &files {
let abs_path = dir.join(rel_path);
let archive_path = format!("{prefix}/{rel_path}");
tar.append_path_with_name(&abs_path, &archive_path)
.map_err(|e| PackageError::ArchiveError(format!("failed to add {rel_path}: {e}")))?;
}
tar.into_inner()
.map_err(|e| PackageError::ArchiveError(format!("failed to finish bz2 stream: {e}")))?
.finish()
.map_err(|e| PackageError::ArchiveError(format!("failed to finish bz2 stream: {e}")))?;
Ok(PackResult {
path: out_path,
unsigned,
})
}
pub fn unpack_package(archive: &Path, dest: &Path) -> Result<PathBuf, PackageError> {
use bzip2::read::BzDecoder;
let file = std::fs::File::open(archive).map_err(|e| {
PackageError::ArchiveError(format!("failed to open {}: {e}", archive.display()))
})?;
let decoder = BzDecoder::new(file);
let mut tar = tar::Archive::new(decoder);
tar.unpack(dest).map_err(|e| {
PackageError::ArchiveError(format!("failed to extract {}: {e}", archive.display()))
})?;
let entries = std::fs::read_dir(dest).map_err(PackageError::Io)?;
let mut pkg_dir: Option<PathBuf> = None;
for entry in entries {
let entry = entry.map_err(PackageError::Io)?;
let path = entry.path();
if path.is_dir() && path.join("package.toml").exists() {
pkg_dir = Some(path);
break;
}
}
let pkg_dir = pkg_dir.ok_or_else(|| {
PackageError::InvalidArchive("archive does not contain a package.toml".to_string())
})?;
Ok(pkg_dir)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write_manifest(dir: &Path, content: &str) {
std::fs::write(dir.join("package.toml"), content).unwrap();
}
#[derive(Debug, Deserialize, PartialEq)]
struct TestMeta {
category: String,
#[serde(default)]
tags: Vec<String>,
}
#[test]
fn valid_manifest_parses() {
let tmp = TempDir::new().unwrap();
write_manifest(
tmp.path(),
r#"
[package]
name = "test-pkg"
version = "1.0.0"
interface = "my-api"
interface_version = 1
[metadata]
category = "testing"
tags = ["a", "b"]
"#,
);
let m = load_manifest::<TestMeta>(tmp.path()).unwrap();
assert_eq!(m.package.name, "test-pkg");
assert_eq!(m.package.version, "1.0.0");
assert_eq!(m.package.interface, "my-api");
assert_eq!(m.package.interface_version, 1);
assert_eq!(m.metadata.category, "testing");
assert_eq!(m.metadata.tags, vec!["a", "b"]);
}
#[test]
fn missing_required_metadata_field_fails() {
let tmp = TempDir::new().unwrap();
write_manifest(
tmp.path(),
r#"
[package]
name = "bad-pkg"
version = "1.0.0"
interface = "my-api"
interface_version = 1
[metadata]
# missing required "category" field
tags = ["x"]
"#,
);
let result = load_manifest::<TestMeta>(tmp.path());
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("category"),
"error should mention missing field: {err}"
);
}
#[test]
fn missing_manifest_returns_not_found() {
let tmp = TempDir::new().unwrap();
let result = load_manifest::<TestMeta>(tmp.path());
assert!(matches!(result, Err(PackageError::ManifestNotFound { .. })));
}
#[test]
fn extra_metadata_fields_ignored() {
let tmp = TempDir::new().unwrap();
write_manifest(
tmp.path(),
r#"
[package]
name = "extra-pkg"
version = "1.0.0"
interface = "my-api"
interface_version = 1
[metadata]
category = "testing"
unknown_field = "ignored"
"#,
);
let m = load_manifest::<TestMeta>(tmp.path());
assert!(m.is_ok());
assert_eq!(m.unwrap().metadata.category, "testing");
}
#[test]
fn untyped_manifest_accepts_any_metadata() {
let tmp = TempDir::new().unwrap();
write_manifest(
tmp.path(),
r#"
[package]
name = "any-pkg"
version = "1.0.0"
interface = "my-api"
interface_version = 1
[metadata]
foo = "bar"
count = 42
nested = { a = 1, b = 2 }
"#,
);
let m = load_manifest_untyped(tmp.path()).unwrap();
assert_eq!(m.package.name, "any-pkg");
assert!(m.metadata.is_table());
}
#[test]
fn digest_is_deterministic() {
let tmp = TempDir::new().unwrap();
write_manifest(tmp.path(), "[package]\nname = \"test\"\nversion = \"1.0.0\"\ninterface = \"api\"\ninterface_version = 1\n\n[metadata]\nk = \"v\"\n");
std::fs::write(tmp.path().join("src.rs"), b"fn main() {}").unwrap();
let d1 = package_digest(tmp.path()).unwrap();
let d2 = package_digest(tmp.path()).unwrap();
assert_eq!(d1, d2);
}
#[test]
fn digest_changes_on_file_modification() {
let tmp = TempDir::new().unwrap();
write_manifest(tmp.path(), "[package]\nname = \"test\"\nversion = \"1.0.0\"\ninterface = \"api\"\ninterface_version = 1\n\n[metadata]\nk = \"v\"\n");
std::fs::write(tmp.path().join("src.rs"), b"fn main() {}").unwrap();
let d1 = package_digest(tmp.path()).unwrap();
std::fs::write(tmp.path().join("src.rs"), b"fn main() { evil() }").unwrap();
let d2 = package_digest(tmp.path()).unwrap();
assert_ne!(d1, d2);
}
#[test]
fn digest_excludes_target_and_sig() {
let tmp = TempDir::new().unwrap();
write_manifest(tmp.path(), "[package]\nname = \"test\"\nversion = \"1.0.0\"\ninterface = \"api\"\ninterface_version = 1\n\n[metadata]\nk = \"v\"\n");
std::fs::write(tmp.path().join("src.rs"), b"fn main() {}").unwrap();
let d1 = package_digest(tmp.path()).unwrap();
std::fs::create_dir(tmp.path().join("target")).unwrap();
std::fs::write(tmp.path().join("target/output.dylib"), b"binary").unwrap();
std::fs::write(tmp.path().join("package.sig"), b"sig bytes").unwrap();
let d2 = package_digest(tmp.path()).unwrap();
assert_eq!(d1, d2);
}
fn make_package(dir: &Path) {
write_manifest(
dir,
r#"
[package]
name = "test-pkg"
version = "2.0.0"
interface = "my-api"
interface_version = 1
[metadata]
category = "testing"
"#,
);
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), b"fn hello() {}").unwrap();
}
#[test]
fn pack_unpack_round_trip() {
let pkg_dir = TempDir::new().unwrap();
make_package(pkg_dir.path());
let out_dir = TempDir::new().unwrap();
let fid_path = out_dir.path().join("test-pkg-2.0.0.fid");
let result = pack_package(pkg_dir.path(), Some(&fid_path)).unwrap();
assert_eq!(result.path, fid_path);
assert!(fid_path.exists());
assert!(result.unsigned);
let extract_dir = TempDir::new().unwrap();
let extracted = unpack_package(&fid_path, extract_dir.path()).unwrap();
assert!(extracted.join("package.toml").exists());
assert!(extracted.join("src/lib.rs").exists());
assert_eq!(
extracted.file_name().unwrap().to_str().unwrap(),
"test-pkg-2.0.0"
);
}
#[test]
fn pack_includes_sig_file() {
let pkg_dir = TempDir::new().unwrap();
make_package(pkg_dir.path());
std::fs::write(pkg_dir.path().join("package.sig"), b"fake-sig").unwrap();
let out_dir = TempDir::new().unwrap();
let fid_path = out_dir.path().join("out.fid");
let result = pack_package(pkg_dir.path(), Some(&fid_path)).unwrap();
assert!(!result.unsigned);
let extract_dir = TempDir::new().unwrap();
let extracted = unpack_package(&fid_path, extract_dir.path()).unwrap();
assert!(extracted.join("package.sig").exists());
}
#[test]
fn pack_excludes_target_and_git() {
let pkg_dir = TempDir::new().unwrap();
make_package(pkg_dir.path());
std::fs::create_dir(pkg_dir.path().join("target")).unwrap();
std::fs::write(pkg_dir.path().join("target/out.dylib"), b"bin").unwrap();
std::fs::create_dir(pkg_dir.path().join(".git")).unwrap();
std::fs::write(pkg_dir.path().join(".git/HEAD"), b"ref").unwrap();
let out_dir = TempDir::new().unwrap();
let fid_path = out_dir.path().join("out.fid");
pack_package(pkg_dir.path(), Some(&fid_path)).unwrap();
let extract_dir = TempDir::new().unwrap();
let extracted = unpack_package(&fid_path, extract_dir.path()).unwrap();
assert!(!extracted.join("target").exists());
assert!(!extracted.join(".git").exists());
}
#[test]
fn unpack_invalid_archive_no_manifest() {
let pkg_dir = TempDir::new().unwrap();
std::fs::create_dir_all(pkg_dir.path().join("src")).unwrap();
std::fs::write(pkg_dir.path().join("src/lib.rs"), b"fn x() {}").unwrap();
let out_dir = TempDir::new().unwrap();
let fid_path = out_dir.path().join("bad.fid");
{
use bzip2::write::BzEncoder;
use bzip2::Compression;
let file = std::fs::File::create(&fid_path).unwrap();
let encoder = BzEncoder::new(file, Compression::default());
let mut tar = tar::Builder::new(encoder);
tar.append_path_with_name(
pkg_dir.path().join("src/lib.rs"),
"no-manifest-1.0.0/src/lib.rs",
)
.unwrap();
tar.into_inner().unwrap().finish().unwrap();
}
let extract_dir = TempDir::new().unwrap();
let result = unpack_package(&fid_path, extract_dir.path());
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("package.toml"), "error was: {err}");
}
#[test]
fn pack_default_output_name() {
let pkg_dir = TempDir::new().unwrap();
make_package(pkg_dir.path());
let out_dir = TempDir::new().unwrap();
let out_path = out_dir.path().join("test-pkg-2.0.0.fid");
let result = pack_package(pkg_dir.path(), Some(&out_path)).unwrap();
assert_eq!(result.path, out_path);
assert!(out_path.exists());
}
#[test]
fn pack_custom_extension() {
let pkg_dir = TempDir::new().unwrap();
write_manifest(
pkg_dir.path(),
r#"
[package]
name = "my-plugin"
version = "0.3.0"
interface = "my-api"
interface_version = 1
extension = "cloacina"
[metadata]
category = "testing"
"#,
);
std::fs::create_dir_all(pkg_dir.path().join("src")).unwrap();
std::fs::write(pkg_dir.path().join("src/lib.rs"), b"fn hello() {}").unwrap();
let out_dir = TempDir::new().unwrap();
let out_path = out_dir.path().join("my-plugin-0.3.0.cloacina");
let result = pack_package(pkg_dir.path(), Some(&out_path)).unwrap();
assert_eq!(result.path, out_path);
assert!(out_path.exists());
let extract_dir = TempDir::new().unwrap();
let extracted = unpack_package(&out_path, extract_dir.path()).unwrap();
assert!(extracted.join("package.toml").exists());
}
#[test]
fn extension_defaults_to_fid() {
let header = PackageHeader {
name: "test".to_string(),
version: "1.0.0".to_string(),
interface: "api".to_string(),
interface_version: 1,
extension: None,
};
assert_eq!(header.extension(), "fid");
let header_custom = PackageHeader {
extension: Some("cloacina".to_string()),
..header
};
assert_eq!(header_custom.extension(), "cloacina");
}
}