crablock-core 0.1.1

Core library for crablock - encryption, package format, and common utilities
Documentation
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::crypto::EncryptionAlgorithm;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommandSpec {
    // Commands are stored as argv parts instead of shell strings.
    // This keeps runtime execution predictable and easier to validate.
    pub run: Vec<String>,
}

impl CommandSpec {
    pub fn new(run: Vec<String>) -> Self {
        Self { run }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EmbeddedEnv {
    // We store only metadata here.
    // The secret `.env` bytes live in a separate encrypted payload section.
    pub target_path: String,
    pub nonce: String,
    pub payload_hash_sha256: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Manifest {
    // These top fields identify the package and protect its integrity.
    pub package_id: Uuid,
    pub artifact_name: String,
    pub artifact_size: u64,
    pub created_at: DateTime<Utc>,
    pub encryption_algorithm: EncryptionAlgorithm,
    pub nonce: String,
    pub artifact_hash_sha256: String,
    pub payload_hash_sha256: String,
    pub version: Option<String>,
    pub profile: Option<String>,
    // These optional fields tell the runtime how to treat a directory-style app.
    pub artifact_kind: Option<String>,
    pub archive_format: Option<String>,
    pub runtime: Option<String>,
    pub framework: Option<String>,
    pub app_root: Option<String>,
    pub start_command: Option<Vec<String>>,
    pub setup_commands: Option<Vec<CommandSpec>>,
    pub required_runtimes: Option<Vec<String>>,
    pub writable_paths: Option<Vec<String>>,
    pub entrypoint: Option<String>,
    pub args: Option<Vec<String>>,
    pub env: Option<Vec<(String, String)>>,
    pub embedded_env: Option<EmbeddedEnv>,
    pub signature_algorithm: Option<String>,
    pub signing_pubkey_fingerprint: Option<String>,
    pub signature_created_at: Option<String>,
    pub require_signature: Option<bool>,
}

impl Manifest {
    pub fn new(
        artifact_name: String,
        artifact_size: u64,
        encryption_algorithm: EncryptionAlgorithm,
        nonce: &[u8],
        artifact_hash: &str,
        payload_hash: &str,
    ) -> Self {
        // New packages start with only the required metadata.
        // Extra behavior is layered on with the `with_*` builder helpers below.
        Self {
            package_id: Uuid::new_v4(),
            artifact_name,
            artifact_size,
            created_at: Utc::now(),
            encryption_algorithm,
            nonce: hex::encode(nonce),
            artifact_hash_sha256: artifact_hash.to_string(),
            payload_hash_sha256: payload_hash.to_string(),
            version: None,
            profile: None,
            artifact_kind: None,
            archive_format: None,
            runtime: None,
            framework: None,
            app_root: None,
            start_command: None,
            setup_commands: None,
            required_runtimes: None,
            writable_paths: None,
            entrypoint: None,
            args: None,
            env: None,
            embedded_env: None,
            signature_algorithm: None,
            signing_pubkey_fingerprint: None,
            signature_created_at: None,
            require_signature: None,
        }
    }

    pub fn with_version(mut self, version: String) -> Self {
        self.version = Some(version);
        self
    }

    pub fn with_profile(mut self, profile: String) -> Self {
        self.profile = Some(profile);
        self
    }

    pub fn with_artifact_kind(mut self, artifact_kind: String) -> Self {
        self.artifact_kind = Some(artifact_kind);
        self
    }

    pub fn with_archive_format(mut self, archive_format: String) -> Self {
        self.archive_format = Some(archive_format);
        self
    }

    pub fn with_runtime(mut self, runtime: String) -> Self {
        self.runtime = Some(runtime);
        self
    }

    pub fn with_framework(mut self, framework: String) -> Self {
        self.framework = Some(framework);
        self
    }

    pub fn with_app_root(mut self, app_root: String) -> Self {
        self.app_root = Some(app_root);
        self
    }

    pub fn with_start_command(mut self, start_command: Vec<String>) -> Self {
        self.start_command = Some(start_command);
        self
    }

    pub fn with_setup_commands(mut self, setup_commands: Vec<CommandSpec>) -> Self {
        self.setup_commands = Some(setup_commands);
        self
    }

    pub fn with_required_runtimes(mut self, required_runtimes: Vec<String>) -> Self {
        self.required_runtimes = Some(required_runtimes);
        self
    }

    pub fn with_writable_paths(mut self, writable_paths: Vec<String>) -> Self {
        self.writable_paths = Some(writable_paths);
        self
    }

    pub fn with_entrypoint(mut self, entrypoint: String) -> Self {
        self.entrypoint = Some(entrypoint);
        self
    }

    pub fn with_args(mut self, args: Vec<String>) -> Self {
        self.args = Some(args);
        self
    }

    pub fn with_env(mut self, env: Vec<(String, String)>) -> Self {
        self.env = Some(env);
        self
    }

    pub fn with_embedded_env(mut self, embedded_env: EmbeddedEnv) -> Self {
        self.embedded_env = Some(embedded_env);
        self
    }

    pub fn with_signature_metadata(
        mut self,
        algorithm: String,
        fingerprint: String,
        created_at: String,
    ) -> Self {
        self.signature_algorithm = Some(algorithm);
        self.signing_pubkey_fingerprint = Some(fingerprint);
        self.signature_created_at = Some(created_at);
        self
    }

    pub fn with_require_signature(mut self, require_signature: bool) -> Self {
        self.require_signature = Some(require_signature);
        self
    }

    pub fn to_json(&self) -> crate::error::Result<String> {
        serde_json::to_string_pretty(self)
            .map_err(|e| crate::error::CrablockError::Serialization(format!("JSON: {e}")))
    }

    pub fn from_json(json: &str) -> crate::error::Result<Self> {
        serde_json::from_str(json)
            .map_err(|e| crate::error::CrablockError::Serialization(format!("JSON: {e}")))
    }

    pub fn to_cbor(&self) -> crate::error::Result<Vec<u8>> {
        let mut buf = Vec::new();
        ciborium::into_writer(self, &mut buf)
            .map_err(|e| crate::error::CrablockError::Serialization(format!("CBOR: {e}")))?;
        Ok(buf)
    }

    pub fn from_cbor(data: &[u8]) -> crate::error::Result<Self> {
        ciborium::from_reader(data)
            .map_err(|e| crate::error::CrablockError::Serialization(format!("CBOR: {e}")))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_manifest_serialization() {
        let manifest = Manifest::new(
            "test_app".to_string(),
            1024,
            EncryptionAlgorithm::Aes256Gcm,
            &[0u8; 12],
            "abc123",
            "def456",
        )
        .with_version("1.0.0".to_string())
        .with_profile("laravel".to_string())
        .with_artifact_kind("directory_archive".to_string())
        .with_archive_format("tar.gz".to_string())
        .with_runtime("php".to_string())
        .with_framework("laravel".to_string())
        .with_app_root(".".to_string())
        .with_start_command(vec![
            "php".to_string(),
            "artisan".to_string(),
            "serve".to_string(),
        ])
        .with_setup_commands(vec![CommandSpec::new(vec![
            "composer".to_string(),
            "install".to_string(),
        ])])
        .with_required_runtimes(vec!["php".to_string()])
        .with_writable_paths(vec!["storage".to_string(), "bootstrap/cache".to_string()])
        .with_entrypoint("/bin/app".to_string())
        .with_args(vec!["--port".to_string(), "3000".to_string()])
        .with_env(vec![
            ("ENV".to_string(), "production".to_string()),
            ("DEBUG".to_string(), "false".to_string()),
        ])
        .with_embedded_env(EmbeddedEnv {
            target_path: ".env".to_string(),
            nonce: "00112233445566778899aabb".to_string(),
            payload_hash_sha256: "feedface".to_string(),
        });

        let json = manifest.to_json().unwrap();
        let deserialized = Manifest::from_json(&json).unwrap();

        assert_eq!(manifest.package_id, deserialized.package_id);
        assert_eq!(manifest.artifact_name, deserialized.artifact_name);
        assert_eq!(manifest.version, deserialized.version);
        assert_eq!(manifest.profile, deserialized.profile);
        assert_eq!(manifest.artifact_kind, deserialized.artifact_kind);
        assert_eq!(manifest.runtime, deserialized.runtime);
        assert_eq!(manifest.framework, deserialized.framework);
        assert_eq!(manifest.app_root, deserialized.app_root);
        assert_eq!(manifest.start_command, deserialized.start_command);
        assert_eq!(manifest.setup_commands, deserialized.setup_commands);
        assert_eq!(manifest.required_runtimes, deserialized.required_runtimes);
        assert_eq!(manifest.writable_paths, deserialized.writable_paths);
        assert_eq!(manifest.entrypoint, deserialized.entrypoint);
        assert_eq!(manifest.embedded_env, deserialized.embedded_env);
    }

    #[test]
    fn test_manifest_cbor() {
        let manifest = Manifest::new(
            "test_app".to_string(),
            1024,
            EncryptionAlgorithm::ChaCha20Poly1305,
            &[1u8; 12],
            "hash1",
            "hash2",
        );

        let cbor = manifest.to_cbor().unwrap();
        let deserialized = Manifest::from_cbor(&cbor).unwrap();

        assert_eq!(manifest.package_id, deserialized.package_id);
        assert_eq!(
            manifest.encryption_algorithm,
            deserialized.encryption_algorithm
        );
    }
}