use chrono::{Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use thiserror::Error; use uuid::Uuid; use log::{error}; #[cfg(target_arch = "wasm32")] use wasm_bindgen::prelude::*; /// Signature for a job - contains the signatory's public key and their signature #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JobSignature { /// Public key of the signatory (hex-encoded secp256k1 public key) pub public_key: String, /// Signature (hex-encoded secp256k1 signature) pub signature: String, } /// Job status enumeration #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum JobStatus { Created, Dispatched, WaitingForPrerequisites, Started, Error, Stopping, Finished, } /// Job result response for RPC calls #[derive(Debug, Serialize, Clone)] #[serde(untagged)] pub enum JobResult { Success { success: String }, Error { error: String }, } impl JobStatus { pub fn as_str(&self) -> &'static str { match self { JobStatus::Created => "created", JobStatus::Dispatched => "dispatched", JobStatus::WaitingForPrerequisites => "waiting_for_prerequisites", JobStatus::Started => "started", JobStatus::Error => "error", JobStatus::Stopping => "stopping", JobStatus::Finished => "finished", } } pub fn from_str(s: &str) -> Option { match s { "created" => Some(JobStatus::Created), "dispatched" => Some(JobStatus::Dispatched), "waiting_for_prerequisites" => Some(JobStatus::WaitingForPrerequisites), "started" => Some(JobStatus::Started), "error" => Some(JobStatus::Error), "stopping" => Some(JobStatus::Stopping), "finished" => Some(JobStatus::Finished), _ => None, } } } /// Job structure representing a unit of work to be executed #[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter_with_clone))] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Job { pub id: String, pub caller_id: String, pub context_id: String, pub payload: String, pub runner: String, // name of the runner to execute this job pub executor: String, // name of the executor the runner will use to execute this job pub timeout: u64, // timeout in seconds #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))] pub env_vars: HashMap, // environment variables for script execution #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))] pub created_at: chrono::DateTime, #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))] pub updated_at: chrono::DateTime, /// Signatures from authorized signatories (public keys are included in each signature) #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))] pub signatures: Vec, } /// Error types for job operations #[derive(Error, Debug)] pub enum JobError { #[error("Serialization error: {0}")] Serialization(#[from] serde_json::Error), #[error("Job not found: {0}")] NotFound(String), #[error("Invalid data: {0}")] InvalidData(String), #[error("Validation error: {0}")] Validation(String), #[error("Signature verification failed: {0}")] SignatureVerification(String), } impl Job { /// Create a new job with the given parameters pub fn new( caller_id: String, context_id: String, payload: String, runner: String, executor: String, ) -> Self { let now = Utc::now(); Self { id: Uuid::new_v4().to_string(), caller_id, context_id, payload, runner, executor, timeout: 300, // 5 minutes default env_vars: HashMap::new(), created_at: now, updated_at: now, signatures: Vec::new(), } } /// Get the canonical representation of the job for signing /// This creates a deterministic string representation that can be hashed and signed /// Note: Signatures are excluded from the canonical representation pub fn canonical_representation(&self) -> String { // Create a deterministic representation excluding signatures // Sort env_vars keys for deterministic ordering let mut env_vars_sorted: Vec<_> = self.env_vars.iter().collect(); env_vars_sorted.sort_by_key(|&(k, _)| k); format!( "{}:{}:{}:{}:{}:{}:{}:{:?}", self.id, self.caller_id, self.context_id, self.payload, self.runner, self.executor, self.timeout, env_vars_sorted ) } /// Get list of signatory public keys from signatures pub fn signatories(&self) -> Vec { self.signatures.iter() .map(|sig| sig.public_key.clone()) .collect() } /// Verify that all signatures are valid /// Returns Ok(()) if verification passes, Err otherwise /// Empty signatures list is allowed - loop simply won't execute pub fn verify_signatures(&self) -> Result<(), JobError> { use secp256k1::{Message, PublicKey, Secp256k1, ecdsa::Signature}; use sha2::{Sha256, Digest}; // Get the canonical representation and hash it let canonical = self.canonical_representation(); let mut hasher = Sha256::new(); hasher.update(canonical.as_bytes()); let hash = hasher.finalize(); let secp = Secp256k1::verification_only(); let message = Message::from_digest_slice(&hash) .map_err(|e| JobError::SignatureVerification(format!("Invalid message: {}", e)))?; // Verify each signature (if any) for sig_data in &self.signatures { // Decode public key let pubkey_bytes = hex::decode(&sig_data.public_key) .map_err(|e| JobError::SignatureVerification(format!("Invalid public key hex: {}", e)))?; let pubkey = PublicKey::from_slice(&pubkey_bytes) .map_err(|e| JobError::SignatureVerification(format!("Invalid public key: {}", e)))?; // Decode signature let sig_bytes = hex::decode(&sig_data.signature) .map_err(|e| JobError::SignatureVerification(format!("Invalid signature hex: {}", e)))?; let signature = Signature::from_compact(&sig_bytes) .map_err(|e| JobError::SignatureVerification(format!("Invalid signature: {}", e)))?; // Verify signature secp.verify_ecdsa(&message, &signature, &pubkey) .map_err(|e| JobError::SignatureVerification(format!("Signature verification failed: {}", e)))?; } Ok(()) } } /// Builder for constructing job execution requests. pub struct JobBuilder { caller_id: String, context_id: String, payload: String, runner: String, executor: String, timeout: u64, // timeout in seconds env_vars: HashMap, signatures: Vec, } impl JobBuilder { pub fn new() -> Self { Self { caller_id: "".to_string(), context_id: "".to_string(), payload: "".to_string(), runner: "".to_string(), executor: "".to_string(), timeout: 300, // 5 minutes default env_vars: HashMap::new(), signatures: Vec::new(), } } /// Set the caller ID for this job pub fn caller_id(mut self, caller_id: &str) -> Self { self.caller_id = caller_id.to_string(); self } /// Set the context ID for this job pub fn context_id(mut self, context_id: &str) -> Self { self.context_id = context_id.to_string(); self } /// Set the payload (script content) for this job pub fn payload(mut self, payload: &str) -> Self { self.payload = payload.to_string(); self } /// Set the runner name for this job pub fn runner(mut self, runner: &str) -> Self { self.runner = runner.to_string(); self } /// Set the executor for this job pub fn executor(mut self, executor: &str) -> Self { self.executor = executor.to_string(); self } /// Set the timeout for job execution (in seconds) pub fn timeout(mut self, timeout: u64) -> Self { self.timeout = timeout; self } /// Set a single environment variable pub fn env_var(mut self, key: &str, value: &str) -> Self { self.env_vars.insert(key.to_string(), value.to_string()); self } /// Set multiple environment variables from a HashMap pub fn env_vars(mut self, env_vars: HashMap) -> Self { self.env_vars = env_vars; self } /// Clear all environment variables pub fn clear_env_vars(mut self) -> Self { self.env_vars.clear(); self } /// Add a signature (public key and signature) pub fn signature(mut self, public_key: &str, signature: &str) -> Self { self.signatures.push(JobSignature { public_key: public_key.to_string(), signature: signature.to_string(), }); self } /// Set multiple signatures pub fn signatures(mut self, signatures: Vec) -> Self { self.signatures = signatures; self } /// Clear all signatures pub fn clear_signatures(mut self) -> Self { self.signatures.clear(); self } /// Build the job pub fn build(self) -> Result { if self.caller_id.is_empty() { return Err(JobError::InvalidData("caller_id is required".to_string())); } if self.context_id.is_empty() { return Err(JobError::InvalidData("context_id is required".to_string())); } if self.payload.is_empty() { return Err(JobError::InvalidData("payload is required".to_string())); } if self.runner.is_empty() { return Err(JobError::InvalidData("runner is required".to_string())); } if self.executor.is_empty() { return Err(JobError::InvalidData("executor is required".to_string())); } let mut job = Job::new( self.caller_id, self.context_id, self.payload, self.runner, self.executor, ); job.timeout = self.timeout; job.env_vars = self.env_vars; job.signatures = self.signatures; Ok(job) } } impl Default for JobBuilder { fn default() -> Self { Self::new() } }