use crate::error::{EngramError, Result};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub version: String,
pub id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub author: Author,
pub metadata: Metadata,
#[serde(default)]
pub capabilities: Vec<String>,
#[serde(default)]
pub files: Vec<FileEntry>,
#[serde(default)]
pub signatures: Vec<SignatureEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Author {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
impl Author {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
email: None,
url: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Metadata {
pub version: String,
pub created: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub modified: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileEntry {
pub path: String,
pub sha256: String,
pub size: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignatureEntry {
pub algorithm: String,
pub public_key: String,
pub signature: String,
pub timestamp: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub signer: Option<String>,
}
impl Manifest {
pub fn new(id: String, name: String, author: Author, version: String) -> Self {
Self {
version: "0.4.0".to_string(),
id,
name,
description: None,
author,
metadata: Metadata {
version,
created: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
modified: None,
license: None,
tags: Vec::new(),
},
capabilities: Vec::new(),
files: Vec::new(),
signatures: Vec::new(),
}
}
pub fn add_file(&mut self, path: String, data: &[u8], mime_type: Option<String>) {
let sha256 = hex::encode(Sha256::digest(data));
self.files.push(FileEntry {
path,
sha256,
size: data.len() as u64,
mime_type,
});
}
pub fn to_json(&self) -> Result<Vec<u8>> {
serde_json::to_vec_pretty(self).map_err(EngramError::from)
}
pub fn from_json(data: &[u8]) -> Result<Self> {
serde_json::from_slice(data).map_err(EngramError::from)
}
pub fn canonical_hash(&self) -> Result<[u8; 32]> {
let mut manifest_copy = self.clone();
manifest_copy.signatures.clear();
let json = serde_json::to_vec(&manifest_copy)?;
Ok(Sha256::digest(&json).into())
}
pub fn sign(&mut self, signing_key: &SigningKey, signer: Option<String>) -> Result<()> {
let hash = self.canonical_hash()?;
let signature = signing_key.sign(&hash);
let public_key = signing_key.verifying_key();
self.signatures.push(SignatureEntry {
algorithm: "ed25519".to_string(),
public_key: hex::encode(public_key.to_bytes()),
signature: hex::encode(signature.to_bytes()),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
signer,
});
Ok(())
}
pub fn verify_signatures(&self) -> Result<Vec<bool>> {
let mut results = Vec::new();
let hash = self.canonical_hash()?;
for sig_entry in &self.signatures {
let result = self.verify_signature_entry(sig_entry, &hash);
results.push(result.is_ok());
}
Ok(results)
}
fn verify_signature_entry(&self, entry: &SignatureEntry, hash: &[u8; 32]) -> Result<()> {
if entry.algorithm != "ed25519" {
return Err(EngramError::InvalidSignature);
}
let public_key_bytes =
hex::decode(&entry.public_key).map_err(|_| EngramError::InvalidPublicKey)?;
let public_key_array: [u8; 32] = public_key_bytes
.try_into()
.map_err(|_| EngramError::InvalidPublicKey)?;
let public_key = VerifyingKey::from_bytes(&public_key_array)?;
let signature_bytes =
hex::decode(&entry.signature).map_err(|_| EngramError::InvalidSignature)?;
let signature_array: [u8; 64] = signature_bytes
.try_into()
.map_err(|_| EngramError::InvalidSignature)?;
let signature = Signature::from_bytes(&signature_array);
public_key.verify(hash, &signature)?;
Ok(())
}
pub fn is_fully_signed(&self) -> Result<bool> {
if self.signatures.is_empty() {
return Ok(false);
}
let results = self.verify_signatures()?;
Ok(results.iter().all(|&valid| valid))
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::rngs::OsRng;
#[test]
fn test_manifest_creation() {
let manifest = Manifest::new(
"test-engram".to_string(),
"Test Engram".to_string(),
Author {
name: "Test Author".to_string(),
email: Some("test@example.com".to_string()),
url: None,
},
"0.1.0".to_string(),
);
assert_eq!(manifest.version, "0.4.0");
assert_eq!(manifest.id, "test-engram");
assert_eq!(manifest.author.name, "Test Author");
}
#[test]
fn test_add_file() {
let mut manifest = Manifest::new(
"test".to_string(),
"Test".to_string(),
Author {
name: "Test".to_string(),
email: None,
url: None,
},
"0.1.0".to_string(),
);
let data = b"Hello, World!";
manifest.add_file("test.txt".to_string(), data, Some("text/plain".to_string()));
assert_eq!(manifest.files.len(), 1);
assert_eq!(manifest.files[0].path, "test.txt");
assert_eq!(manifest.files[0].size, 13);
}
#[test]
fn test_signature_roundtrip() {
let mut manifest = Manifest::new(
"test".to_string(),
"Test".to_string(),
Author {
name: "Test".to_string(),
email: None,
url: None,
},
"0.1.0".to_string(),
);
let mut csprng = OsRng;
let signing_key = SigningKey::generate(&mut csprng);
manifest
.sign(&signing_key, Some("Test Signer".to_string()))
.unwrap();
assert_eq!(manifest.signatures.len(), 1);
assert_eq!(manifest.signatures[0].algorithm, "ed25519");
let results = manifest.verify_signatures().unwrap();
assert_eq!(results.len(), 1);
assert!(results[0]);
assert!(manifest.is_fully_signed().unwrap());
}
#[test]
fn test_json_roundtrip() {
let manifest = Manifest::new(
"test".to_string(),
"Test".to_string(),
Author {
name: "Test".to_string(),
email: None,
url: None,
},
"0.1.0".to_string(),
);
let json = manifest.to_json().unwrap();
let parsed = Manifest::from_json(&json).unwrap();
assert_eq!(parsed.id, manifest.id);
assert_eq!(parsed.name, manifest.name);
}
}