use crate::agent::Agent;
use crate::agent::boilerplate::BoilerPlate;
use crate::agent::document::DocumentTraits;
use crate::create_minimal_blank_agent;
use crate::error::JacsError;
use crate::mime::mime_from_extension;
use crate::schema::utils::{ValueExt, check_document_size};
use base64::Engine;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::fs;
use std::path::Path;
use std::sync::Mutex;
use tracing::{debug, info, warn};
pub fn diagnostics() -> serde_json::Value {
serde_json::json!({
"jacs_version": env!("CARGO_PKG_VERSION"),
"rust_version": option_env!("CARGO_PKG_RUST_VERSION").unwrap_or("unknown"),
"os": std::env::consts::OS,
"arch": std::env::consts::ARCH,
"config_path": std::env::var("JACS_CONFIG").unwrap_or_default(),
"data_directory": std::env::var("JACS_DATA_DIRECTORY").unwrap_or_default(),
"key_directory": std::env::var("JACS_KEY_DIRECTORY").unwrap_or_default(),
"key_algorithm": std::env::var("JACS_AGENT_KEY_ALGORITHM").unwrap_or_default(),
"default_storage": std::env::var("JACS_DEFAULT_STORAGE").unwrap_or_default(),
"strict_mode": std::env::var("JACS_STRICT_MODE").unwrap_or_default(),
"agent_loaded": false,
"agent_id": serde_json::Value::Null,
})
}
pub const MAX_VERIFY_URL_LEN: usize = 2048;
pub const MAX_VERIFY_DOCUMENT_BYTES: usize = 1515;
const DEFAULT_PRIVATE_KEY_FILENAME: &str = "jacs.private.pem.enc";
const DEFAULT_PUBLIC_KEY_FILENAME: &str = "jacs.public.pem";
fn build_agent_document(
agent_type: &str,
name: &str,
description: &str,
) -> Result<Value, JacsError> {
let template = create_minimal_blank_agent(agent_type.to_string(), None, None, None).map_err(
|e| JacsError::Internal {
message: format!("Failed to create minimal agent template: {}", e),
},
)?;
let mut agent_json: Value = serde_json::from_str(&template).map_err(|e| JacsError::Internal {
message: format!("Failed to parse minimal agent template JSON: {}", e),
})?;
let obj = agent_json.as_object_mut().ok_or_else(|| JacsError::Internal {
message: "Generated minimal agent template is not a JSON object".to_string(),
})?;
obj.insert("name".to_string(), json!(name));
obj.insert("description".to_string(), json!(description));
Ok(agent_json)
}
pub fn generate_verify_link(document: &str, base_url: &str) -> Result<String, JacsError> {
let base = base_url.trim_end_matches('/');
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(document.as_bytes());
let path_and_query = format!("/jacs/verify?s={}", encoded);
let full_url = format!("{}{}", base, path_and_query);
if full_url.len() > MAX_VERIFY_URL_LEN {
return Err(JacsError::ValidationError(format!(
"Verify URL would exceed max length ({}). Document size must be at most {} UTF-8 bytes.",
MAX_VERIFY_URL_LEN, MAX_VERIFY_DOCUMENT_BYTES
)));
}
Ok(full_url)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentInfo {
pub agent_id: String,
pub name: String,
pub public_key_path: String,
pub config_path: String,
#[serde(default)]
pub version: String,
#[serde(default)]
pub algorithm: String,
#[serde(default)]
pub private_key_path: String,
#[serde(default)]
pub data_directory: String,
#[serde(default)]
pub key_directory: String,
#[serde(default)]
pub domain: String,
#[serde(default)]
pub dns_record: String,
#[serde(default)]
pub hai_registered: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedDocument {
pub raw: String,
pub document_id: String,
pub agent_id: String,
pub timestamp: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerificationResult {
pub valid: bool,
pub data: Value,
pub signer_id: String,
pub signer_name: Option<String>,
pub timestamp: String,
pub attachments: Vec<Attachment>,
pub errors: Vec<String>,
}
impl VerificationResult {
#[must_use]
pub fn failure(error: String) -> Self {
Self {
valid: false,
data: json!(null),
signer_id: String::new(),
signer_name: None,
timestamp: String::new(),
attachments: vec![],
errors: vec![error],
}
}
#[must_use]
pub fn success(data: Value, signer_id: String, timestamp: String) -> Self {
Self {
valid: true,
data,
signer_id,
signer_name: None,
timestamp,
attachments: vec![],
errors: vec![],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attachment {
pub filename: String,
pub mime_type: String,
#[serde(with = "base64_bytes")]
pub content: Vec<u8>,
pub hash: String,
pub embedded: bool,
}
mod base64_bytes {
use base64::{Engine as _, engine::general_purpose::STANDARD};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&STANDARD.encode(bytes))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
STANDARD.decode(&s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetupInstructions {
pub dns_record_bind: String,
pub dns_record_value: String,
pub dns_owner: String,
pub provider_commands: std::collections::HashMap<String, String>,
pub dnssec_instructions: std::collections::HashMap<String, String>,
pub tld_requirement: String,
pub well_known_json: String,
pub hai_registration_url: String,
pub hai_registration_payload: String,
pub hai_registration_instructions: String,
pub summary: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateAgentParams {
pub name: String,
#[serde(default)]
pub password: String,
#[serde(default = "default_algorithm")]
pub algorithm: String,
#[serde(default = "default_data_directory")]
pub data_directory: String,
#[serde(default = "default_key_directory")]
pub key_directory: String,
#[serde(default = "default_config_path")]
pub config_path: String,
#[serde(default = "default_agent_type")]
pub agent_type: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub domain: String,
#[serde(default = "default_storage")]
pub default_storage: String,
#[serde(default)]
pub hai_api_key: String,
#[serde(default)]
pub hai_endpoint: String,
}
fn default_algorithm() -> String {
"pq2025".to_string()
}
fn default_data_directory() -> String {
"./jacs_data".to_string()
}
fn default_key_directory() -> String {
"./jacs_keys".to_string()
}
fn default_config_path() -> String {
"./jacs.config.json".to_string()
}
fn default_agent_type() -> String {
"ai".to_string()
}
fn default_storage() -> String {
"fs".to_string()
}
impl Default for CreateAgentParams {
fn default() -> Self {
Self {
name: String::new(),
password: String::new(),
algorithm: default_algorithm(),
data_directory: default_data_directory(),
key_directory: default_key_directory(),
config_path: default_config_path(),
agent_type: default_agent_type(),
description: String::new(),
domain: String::new(),
default_storage: default_storage(),
hai_api_key: String::new(),
hai_endpoint: String::new(),
}
}
}
impl CreateAgentParams {
pub fn builder() -> CreateAgentParamsBuilder {
CreateAgentParamsBuilder::default()
}
}
#[derive(Debug, Clone, Default)]
pub struct CreateAgentParamsBuilder {
params: CreateAgentParams,
}
impl CreateAgentParamsBuilder {
pub fn name(mut self, name: &str) -> Self {
self.params.name = name.to_string();
self
}
pub fn password(mut self, password: &str) -> Self {
self.params.password = password.to_string();
self
}
pub fn algorithm(mut self, algorithm: &str) -> Self {
self.params.algorithm = algorithm.to_string();
self
}
pub fn data_directory(mut self, dir: &str) -> Self {
self.params.data_directory = dir.to_string();
self
}
pub fn key_directory(mut self, dir: &str) -> Self {
self.params.key_directory = dir.to_string();
self
}
pub fn config_path(mut self, path: &str) -> Self {
self.params.config_path = path.to_string();
self
}
pub fn agent_type(mut self, agent_type: &str) -> Self {
self.params.agent_type = agent_type.to_string();
self
}
pub fn description(mut self, desc: &str) -> Self {
self.params.description = desc.to_string();
self
}
pub fn domain(mut self, domain: &str) -> Self {
self.params.domain = domain.to_string();
self
}
pub fn default_storage(mut self, storage: &str) -> Self {
self.params.default_storage = storage.to_string();
self
}
pub fn hai_api_key(mut self, key: &str) -> Self {
self.params.hai_api_key = key.to_string();
self
}
pub fn hai_endpoint(mut self, endpoint: &str) -> Self {
self.params.hai_endpoint = endpoint.to_string();
self
}
pub fn build(self) -> CreateAgentParams {
self.params
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RegistrationInfo {
#[serde(default)]
pub dns_record: String,
#[serde(default)]
pub dns_route53: String,
#[serde(default)]
pub hai_registered: bool,
#[serde(default)]
pub hai_error: String,
}
static CREATE_MUTEX: Mutex<()> = Mutex::new(());
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignerStatus {
pub agent_id: String,
pub signed: bool,
pub signed_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgreementStatus {
pub complete: bool,
pub signers: Vec<SignerStatus>,
pub pending: Vec<String>,
}
pub struct SimpleAgent {
agent: Mutex<Agent>,
config_path: Option<String>,
}
impl SimpleAgent {
#[must_use = "agent creation result must be checked for errors"]
pub fn create(
name: &str,
purpose: Option<&str>,
key_algorithm: Option<&str>,
) -> Result<(Self, AgentInfo), JacsError> {
let algorithm = key_algorithm.unwrap_or("ed25519");
info!(
"Creating new agent '{}' with algorithm '{}'",
name, algorithm
);
let keys_dir = Path::new("./jacs_keys");
let data_dir = Path::new("./jacs_data");
fs::create_dir_all(keys_dir).map_err(|e| JacsError::DirectoryCreateFailed {
path: keys_dir.to_string_lossy().to_string(),
reason: e.to_string(),
})?;
fs::create_dir_all(data_dir).map_err(|e| JacsError::DirectoryCreateFailed {
path: data_dir.to_string_lossy().to_string(),
reason: e.to_string(),
})?;
let agent_type = "ai";
let description = purpose.unwrap_or("JACS agent");
let agent_json = build_agent_document(agent_type, name, description)?;
let mut agent = crate::get_empty_agent();
let instance = agent
.create_agent_and_load(&agent_json.to_string(), true, Some(algorithm))
.map_err(|e| JacsError::Internal {
message: format!("Failed to create agent: {}", e),
})?;
let agent_id = instance["jacsId"].as_str().unwrap_or("unknown").to_string();
let version = instance["jacsVersion"]
.as_str()
.unwrap_or("unknown")
.to_string();
let lookup_id = format!("{}:{}", agent_id, version);
agent.save().map_err(|e| JacsError::Internal {
message: format!("Failed to save agent: {}", e),
})?;
let config_json = json!({
"$schema": "https://hai.ai/schemas/jacs.config.schema.json",
"jacs_agent_id_and_version": lookup_id,
"jacs_data_directory": "./jacs_data",
"jacs_key_directory": "./jacs_keys",
"jacs_agent_private_key_filename": DEFAULT_PRIVATE_KEY_FILENAME,
"jacs_agent_public_key_filename": DEFAULT_PUBLIC_KEY_FILENAME,
"jacs_agent_key_algorithm": algorithm,
"jacs_default_storage": "fs"
});
let config_path = "./jacs.config.json";
let config_str =
serde_json::to_string_pretty(&config_json).map_err(|e| JacsError::Internal {
message: format!("Failed to serialize config: {}", e),
})?;
fs::write(config_path, config_str).map_err(|e| JacsError::Internal {
message: format!("Failed to write config: {}", e),
})?;
info!("Agent '{}' created successfully with ID {}", name, agent_id);
let info = AgentInfo {
agent_id,
name: name.to_string(),
public_key_path: format!("./jacs_keys/{}", DEFAULT_PUBLIC_KEY_FILENAME),
config_path: config_path.to_string(),
version,
algorithm: algorithm.to_string(),
private_key_path: format!("./jacs_keys/{}", DEFAULT_PRIVATE_KEY_FILENAME),
data_directory: "./jacs_data".to_string(),
key_directory: "./jacs_keys".to_string(),
domain: String::new(),
dns_record: String::new(),
hai_registered: false,
};
Ok((
Self {
agent: Mutex::new(agent),
config_path: Some(config_path.to_string()),
},
info,
))
}
#[must_use = "agent creation result must be checked for errors"]
pub fn create_with_params(params: CreateAgentParams) -> Result<(Self, AgentInfo), JacsError> {
struct EnvRestoreGuard {
previous: Vec<(String, Option<String>)>,
}
impl Drop for EnvRestoreGuard {
fn drop(&mut self) {
for (key, value) in &self.previous {
unsafe {
if let Some(v) = value {
std::env::set_var(key, v);
} else {
std::env::remove_var(key);
}
}
}
}
}
let _lock = CREATE_MUTEX.lock().map_err(|e| JacsError::Internal {
message: format!("Failed to acquire creation lock: {}", e),
})?;
let password = if !params.password.is_empty() {
params.password.clone()
} else {
std::env::var("JACS_PRIVATE_KEY_PASSWORD").unwrap_or_default()
};
if password.is_empty() {
return Err(JacsError::ConfigError(
"Password is required for agent creation. \
Either pass it in CreateAgentParams.password or set the JACS_PRIVATE_KEY_PASSWORD environment variable."
.to_string(),
));
}
let algorithm = if params.algorithm.is_empty() {
"pq2025".to_string()
} else {
params.algorithm.clone()
};
info!(
"Creating new agent '{}' with algorithm '{}' (programmatic)",
params.name, algorithm
);
let keys_dir = Path::new(¶ms.key_directory);
let data_dir = Path::new(¶ms.data_directory);
fs::create_dir_all(keys_dir).map_err(|e| JacsError::DirectoryCreateFailed {
path: keys_dir.to_string_lossy().to_string(),
reason: e.to_string(),
})?;
fs::create_dir_all(data_dir.join("agent")).map_err(|e| JacsError::DirectoryCreateFailed {
path: data_dir.join("agent").to_string_lossy().to_string(),
reason: e.to_string(),
})?;
fs::create_dir_all(data_dir.join("public_keys")).map_err(|e| {
JacsError::DirectoryCreateFailed {
path: data_dir.join("public_keys").to_string_lossy().to_string(),
reason: e.to_string(),
}
})?;
let env_keys = [
"JACS_PRIVATE_KEY_PASSWORD",
"JACS_DATA_DIRECTORY",
"JACS_KEY_DIRECTORY",
"JACS_AGENT_KEY_ALGORITHM",
"JACS_DEFAULT_STORAGE",
"JACS_AGENT_PRIVATE_KEY_FILENAME",
"JACS_AGENT_PUBLIC_KEY_FILENAME",
];
let previous_env = env_keys
.iter()
.map(|k| ((*k).to_string(), std::env::var(k).ok()))
.collect();
let _env_restore_guard = EnvRestoreGuard {
previous: previous_env,
};
unsafe {
std::env::set_var("JACS_PRIVATE_KEY_PASSWORD", &password);
std::env::set_var("JACS_DATA_DIRECTORY", ¶ms.data_directory);
std::env::set_var("JACS_KEY_DIRECTORY", ¶ms.key_directory);
std::env::set_var("JACS_AGENT_KEY_ALGORITHM", &algorithm);
std::env::set_var("JACS_DEFAULT_STORAGE", ¶ms.default_storage);
std::env::set_var("JACS_AGENT_PRIVATE_KEY_FILENAME", DEFAULT_PRIVATE_KEY_FILENAME);
std::env::set_var("JACS_AGENT_PUBLIC_KEY_FILENAME", DEFAULT_PUBLIC_KEY_FILENAME);
}
let description = if params.description.is_empty() {
"JACS agent".to_string()
} else {
params.description.clone()
};
let agent_json = build_agent_document(¶ms.agent_type, ¶ms.name, &description)?;
let mut agent = crate::get_empty_agent();
let instance = agent
.create_agent_and_load(&agent_json.to_string(), true, Some(&algorithm))
.map_err(|e| JacsError::Internal {
message: format!("Failed to create agent: {}", e),
})?;
let agent_id = instance["jacsId"].as_str().unwrap_or("unknown").to_string();
let version = instance["jacsVersion"]
.as_str()
.unwrap_or("unknown")
.to_string();
let lookup_id = format!("{}:{}", agent_id, version);
let config_path = Path::new(¶ms.config_path);
let config_str = if config_path.exists() {
let existing_str = fs::read_to_string(config_path).map_err(|e| JacsError::Internal {
message: format!("Failed to read existing config '{}': {}", params.config_path, e),
})?;
let mut existing: serde_json::Value =
serde_json::from_str(&existing_str).map_err(|e| JacsError::Internal {
message: format!("Failed to parse existing config: {}", e),
})?;
let check = |field: &str, existing_val: Option<&str>, param_val: &str| {
if let Some(ev) = existing_val {
if ev != param_val {
warn!(
"Config '{}' differs: existing='{}', param='{}'. Keeping existing value.",
field, ev, param_val
);
}
}
};
check(
"jacs_data_directory",
existing.get("jacs_data_directory").and_then(|v| v.as_str()),
¶ms.data_directory,
);
check(
"jacs_key_directory",
existing.get("jacs_key_directory").and_then(|v| v.as_str()),
¶ms.key_directory,
);
check(
"jacs_agent_key_algorithm",
existing.get("jacs_agent_key_algorithm").and_then(|v| v.as_str()),
&algorithm,
);
check(
"jacs_default_storage",
existing.get("jacs_default_storage").and_then(|v| v.as_str()),
¶ms.default_storage,
);
if let Some(obj) = existing.as_object_mut() {
obj.insert(
"jacs_agent_id_and_version".to_string(),
json!(lookup_id),
);
}
let updated_str =
serde_json::to_string_pretty(&existing).map_err(|e| JacsError::Internal {
message: format!("Failed to serialize updated config: {}", e),
})?;
fs::write(config_path, &updated_str).map_err(|e| JacsError::Internal {
message: format!("Failed to write config to '{}': {}", params.config_path, e),
})?;
info!(
"Updated existing config '{}' with new agent ID {}",
params.config_path, lookup_id
);
updated_str
} else {
let config_json = json!({
"$schema": "https://hai.ai/schemas/jacs.config.schema.json",
"jacs_agent_id_and_version": lookup_id,
"jacs_data_directory": params.data_directory,
"jacs_key_directory": params.key_directory,
"jacs_agent_private_key_filename": DEFAULT_PRIVATE_KEY_FILENAME,
"jacs_agent_public_key_filename": DEFAULT_PUBLIC_KEY_FILENAME,
"jacs_agent_key_algorithm": algorithm,
"jacs_default_storage": params.default_storage,
});
let new_str =
serde_json::to_string_pretty(&config_json).map_err(|e| JacsError::Internal {
message: format!("Failed to serialize config: {}", e),
})?;
if let Some(parent) = config_path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent).map_err(|e| JacsError::DirectoryCreateFailed {
path: parent.to_string_lossy().to_string(),
reason: e.to_string(),
})?;
}
}
fs::write(config_path, &new_str).map_err(|e| JacsError::Internal {
message: format!("Failed to write config to '{}': {}", params.config_path, e),
})?;
info!(
"Created new config '{}' for agent {}",
params.config_path, lookup_id
);
new_str
};
let validated_config_value =
crate::config::validate_config(&config_str).map_err(|e| JacsError::Internal {
message: format!("Failed to validate config: {}", e),
})?;
agent.config = Some(
serde_json::from_value(validated_config_value).map_err(|e| JacsError::Internal {
message: format!("Failed to parse config: {}", e),
})?,
);
agent.save().map_err(|e| JacsError::Internal {
message: format!("Failed to save agent: {}", e),
})?;
let mut dns_record = String::new();
if !params.domain.is_empty() {
if let Ok(pk) = agent.get_public_key() {
let digest = crate::dns::bootstrap::pubkey_digest_b64(&pk);
let rr = crate::dns::bootstrap::build_dns_record(
¶ms.domain,
3600,
&agent_id,
&digest,
crate::dns::bootstrap::DigestEncoding::Base64,
);
dns_record = crate::dns::bootstrap::emit_plain_bind(&rr);
}
}
let private_key_path =
format!("{}/{}", params.key_directory, DEFAULT_PRIVATE_KEY_FILENAME);
let public_key_path = format!("{}/{}", params.key_directory, DEFAULT_PUBLIC_KEY_FILENAME);
info!(
"Agent '{}' created successfully with ID {} (programmatic)",
params.name, agent_id
);
let info = AgentInfo {
agent_id,
name: params.name.clone(),
public_key_path,
config_path: params.config_path.clone(),
version,
algorithm: algorithm.clone(),
private_key_path,
data_directory: params.data_directory.clone(),
key_directory: params.key_directory.clone(),
domain: params.domain.clone(),
dns_record,
hai_registered: false,
};
Ok((
Self {
agent: Mutex::new(agent),
config_path: Some(params.config_path),
},
info,
))
}
#[must_use = "agent loading result must be checked for errors"]
pub fn load(config_path: Option<&str>) -> Result<Self, JacsError> {
let path = config_path.unwrap_or("./jacs.config.json");
debug!("Loading agent from config: {}", path);
if !Path::new(path).exists() {
return Err(JacsError::ConfigNotFound {
path: path.to_string(),
});
}
let mut agent = crate::get_empty_agent();
agent
.load_by_config(path.to_string())
.map_err(|e| JacsError::ConfigInvalid {
field: "config".to_string(),
reason: e.to_string(),
})?;
info!("Agent loaded successfully from {}", path);
Ok(Self {
agent: Mutex::new(agent),
config_path: Some(path.to_string()),
})
}
#[must_use = "self-verification result must be checked"]
pub fn verify_self(&self) -> Result<VerificationResult, JacsError> {
let mut agent = self.agent.lock().map_err(|e| JacsError::Internal {
message: format!("Failed to acquire agent lock: {}", e),
})?;
let sig_result = agent.verify_self_signature();
let hash_result = agent.verify_self_hash();
let mut errors = Vec::new();
if let Err(e) = sig_result {
errors.push(format!("Signature verification failed: {}", e));
}
if let Err(e) = hash_result {
errors.push(format!("Hash verification failed: {}", e));
}
let valid = errors.is_empty();
let agent_value = agent.get_value().cloned().unwrap_or(json!({}));
let agent_id = agent_value.get_str_or("jacsId", "");
let agent_name = agent_value.get_str("name");
let timestamp = agent_value.get_str_or("jacsVersionDate", "");
Ok(VerificationResult {
valid,
data: agent_value,
signer_id: agent_id.clone(),
signer_name: agent_name,
timestamp,
attachments: vec![],
errors,
})
}
#[must_use = "updated agent JSON must be used or stored"]
pub fn update_agent(&self, new_agent_data: &str) -> Result<String, JacsError> {
check_document_size(new_agent_data)?;
let mut agent = self.agent.lock().map_err(|e| JacsError::Internal {
message: format!("Failed to acquire agent lock: {}", e),
})?;
agent
.update_self(new_agent_data)
.map_err(|e| JacsError::Internal {
message: format!("Failed to update agent: {}", e),
})
}
#[must_use = "updated document must be used or stored"]
pub fn update_document(
&self,
document_id: &str,
new_data: &str,
attachments: Option<Vec<String>>,
embed: Option<bool>,
) -> Result<SignedDocument, JacsError> {
check_document_size(new_data)?;
let mut agent = self.agent.lock().map_err(|e| JacsError::Internal {
message: format!("Failed to acquire agent lock: {}", e),
})?;
let jacs_doc = agent
.update_document(document_id, new_data, attachments, embed)
.map_err(|e| JacsError::Internal {
message: format!("Failed to update document: {}", e),
})?;
let raw = serde_json::to_string(&jacs_doc.value).map_err(|e| JacsError::Internal {
message: format!("Failed to serialize document: {}", e),
})?;
let timestamp = jacs_doc
.value
.get_path_str_or(&["jacsSignature", "date"], "");
let agent_id = jacs_doc
.value
.get_path_str_or(&["jacsSignature", "agentID"], "");
Ok(SignedDocument {
raw,
document_id: jacs_doc.id,
agent_id,
timestamp,
})
}
#[must_use = "signed document must be used or stored"]
pub fn sign_message(&self, data: &Value) -> Result<SignedDocument, JacsError> {
debug!("sign_message() called");
let doc_content = json!({
"jacsType": "message",
"jacsLevel": "raw",
"content": data
});
let doc_string = doc_content.to_string();
check_document_size(&doc_string)?;
let mut agent = self.agent.lock().map_err(|e| JacsError::Internal {
message: format!("Failed to acquire agent lock: {}", e),
})?;
let jacs_doc = agent
.create_document_and_load(&doc_string, None, None)
.map_err(|e| JacsError::SigningFailed {
reason: format!(
"{}. Ensure the agent is properly initialized with load() or create() and has valid keys.",
e
),
})?;
let raw = serde_json::to_string(&jacs_doc.value).map_err(|e| JacsError::Internal {
message: format!("Failed to serialize document: {}", e),
})?;
let timestamp = jacs_doc
.value
.get_path_str_or(&["jacsSignature", "date"], "");
let agent_id = jacs_doc
.value
.get_path_str_or(&["jacsSignature", "agentID"], "");
info!("Message signed: document_id={}", jacs_doc.id);
Ok(SignedDocument {
raw,
document_id: jacs_doc.id,
agent_id,
timestamp,
})
}
#[must_use = "signed document must be used or stored"]
pub fn sign_file(&self, file_path: &str, embed: bool) -> Result<SignedDocument, JacsError> {
if !Path::new(file_path).exists() {
return Err(JacsError::FileNotFound {
path: file_path.to_string(),
});
}
let mime_type = mime_from_extension(file_path);
let filename = Path::new(file_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file");
let mut agent = self.agent.lock().map_err(|e| JacsError::Internal {
message: format!("Failed to acquire agent lock: {}", e),
})?;
let doc_content = json!({
"jacsType": "file",
"jacsLevel": "raw",
"filename": filename,
"mimetype": mime_type
});
let attachment = vec![file_path.to_string()];
let jacs_doc = agent
.create_document_and_load(&doc_content.to_string(), Some(attachment), Some(embed))
.map_err(|e| JacsError::SigningFailed {
reason: format!(
"File signing failed for '{}': {}. Verify the file exists and the agent has valid keys.",
file_path, e
),
})?;
let raw = serde_json::to_string(&jacs_doc.value).map_err(|e| JacsError::Internal {
message: format!("Failed to serialize document: {}", e),
})?;
let timestamp = jacs_doc
.value
.get_path_str_or(&["jacsSignature", "date"], "");
let agent_id = jacs_doc
.value
.get_path_str_or(&["jacsSignature", "agentID"], "");
Ok(SignedDocument {
raw,
document_id: jacs_doc.id,
agent_id,
timestamp,
})
}
pub fn sign_messages_batch(
&self,
messages: &[&Value],
) -> Result<Vec<SignedDocument>, JacsError> {
use crate::agent::document::DocumentTraits;
use tracing::info;
if messages.is_empty() {
return Ok(Vec::new());
}
info!(batch_size = messages.len(), "Signing batch of messages");
let doc_strings: Vec<String> = messages
.iter()
.map(|data| {
let doc_content = json!({
"jacsType": "message",
"jacsLevel": "raw",
"content": data
});
doc_content.to_string()
})
.collect();
for doc_str in &doc_strings {
check_document_size(doc_str)?;
}
let mut agent = self.agent.lock().map_err(|e| JacsError::Internal {
message: format!("Failed to acquire agent lock: {}", e),
})?;
let doc_refs: Vec<&str> = doc_strings.iter().map(|s| s.as_str()).collect();
let jacs_docs = agent
.create_documents_batch(&doc_refs)
.map_err(|e| JacsError::SigningFailed {
reason: format!(
"Batch signing failed: {}. Ensure the agent is properly initialized with load() or create() and has valid keys.",
e
),
})?;
let mut results = Vec::with_capacity(jacs_docs.len());
for jacs_doc in jacs_docs {
let raw = serde_json::to_string(&jacs_doc.value).map_err(|e| JacsError::Internal {
message: format!("Failed to serialize document: {}", e),
})?;
let timestamp = jacs_doc
.value
.get_path_str_or(&["jacsSignature", "date"], "");
let agent_id = jacs_doc
.value
.get_path_str_or(&["jacsSignature", "agentID"], "");
results.push(SignedDocument {
raw,
document_id: jacs_doc.id,
agent_id,
timestamp,
});
}
info!(
batch_size = results.len(),
"Batch message signing completed successfully"
);
Ok(results)
}
#[must_use = "verification result must be checked"]
pub fn verify(&self, signed_document: &str) -> Result<VerificationResult, JacsError> {
debug!("verify() called");
let trimmed = signed_document.trim();
if !trimmed.is_empty() && !trimmed.starts_with('{') && !trimmed.starts_with('[') {
return Err(JacsError::DocumentMalformed {
field: "json".to_string(),
reason: format!(
"Input does not appear to be a JSON document. \
If you have a document ID (e.g., 'uuid:version'), use verify_by_id() instead. \
Received: '{}'",
if trimmed.len() > 60 {
&trimmed[..60]
} else {
trimmed
}
),
});
}
check_document_size(signed_document)?;
let _: Value =
serde_json::from_str(signed_document).map_err(|e| JacsError::DocumentMalformed {
field: "json".to_string(),
reason: e.to_string(),
})?;
let mut agent = self.agent.lock().map_err(|e| JacsError::Internal {
message: format!("Failed to acquire agent lock: {}", e),
})?;
let jacs_doc =
agent
.load_document(signed_document)
.map_err(|e| JacsError::DocumentMalformed {
field: "document".to_string(),
reason: e.to_string(),
})?;
let document_key = jacs_doc.getkey();
let verification_result =
agent.verify_document_signature(&document_key, None, None, None, None);
let mut errors = Vec::new();
if let Err(e) = verification_result {
errors.push(e.to_string());
}
if let Err(e) = agent.verify_hash(&jacs_doc.value) {
errors.push(format!("Hash verification failed: {}", e));
}
let valid = errors.is_empty();
let signer_id = jacs_doc
.value
.get_path_str_or(&["jacsSignature", "agentID"], "");
let timestamp = jacs_doc
.value
.get_path_str_or(&["jacsSignature", "date"], "");
info!("Document verified: valid={}, signer={}", valid, signer_id);
let data = if let Some(content) = jacs_doc.value.get("content") {
content.clone()
} else {
jacs_doc.value.clone()
};
let attachments = extract_attachments(&jacs_doc.value);
Ok(VerificationResult {
valid,
data,
signer_id,
signer_name: None, timestamp,
attachments,
errors,
})
}
pub fn reencrypt_key(&self, old_password: &str, new_password: &str) -> Result<(), JacsError> {
use crate::crypt::aes_encrypt::reencrypt_private_key;
let key_path = if let Some(ref config_path) = self.config_path {
let config_str =
fs::read_to_string(config_path).map_err(|e| JacsError::FileReadFailed {
path: config_path.clone(),
reason: e.to_string(),
})?;
let config: Value =
serde_json::from_str(&config_str).map_err(|e| JacsError::ConfigInvalid {
field: "json".to_string(),
reason: e.to_string(),
})?;
let key_dir = config["jacs_key_directory"]
.as_str()
.unwrap_or("./jacs_keys");
let key_filename = config["jacs_agent_private_key_filename"]
.as_str()
.unwrap_or("jacs.private.pem.enc");
format!("{}/{}", key_dir, key_filename)
} else {
"./jacs_keys/jacs.private.pem.enc".to_string()
};
info!("Re-encrypting private key at: {}", key_path);
let encrypted_data = fs::read(&key_path).map_err(|e| JacsError::FileReadFailed {
path: key_path.clone(),
reason: e.to_string(),
})?;
let re_encrypted = reencrypt_private_key(&encrypted_data, old_password, new_password)
.map_err(|e| JacsError::CryptoError(format!("Re-encryption failed: {}", e)))?;
fs::write(&key_path, &re_encrypted).map_err(|e| JacsError::Internal {
message: format!("Failed to write re-encrypted key to '{}': {}", key_path, e),
})?;
info!("Private key re-encrypted successfully");
Ok(())
}
#[must_use = "verification result must be checked"]
pub fn verify_by_id(&self, document_id: &str) -> Result<VerificationResult, JacsError> {
use crate::storage::StorageDocumentTraits;
debug!("verify_by_id() called with id: {}", document_id);
let parts: Vec<&str> = document_id.splitn(2, ':').collect();
if parts.len() != 2 {
return Err(JacsError::DocumentMalformed {
field: "document_id".to_string(),
reason: format!(
"Expected format 'uuid:version', got '{}'. \
Use verify() with the full JSON document string instead.",
document_id
),
});
}
let storage =
crate::storage::MultiStorage::default_new().map_err(|e| JacsError::Internal {
message: format!("Failed to initialize storage: {}", e),
})?;
let jacs_doc = storage
.get_document(document_id)
.map_err(|e| JacsError::Internal {
message: format!(
"Failed to load document '{}' from storage: {}",
document_id, e
),
})?;
let doc_str = serde_json::to_string(&jacs_doc.value).map_err(|e| JacsError::Internal {
message: format!("Failed to serialize document '{}': {}", document_id, e),
})?;
self.verify(&doc_str)
}
#[must_use = "exported agent data must be used"]
pub fn export_agent(&self) -> Result<String, JacsError> {
let agent = self.agent.lock().map_err(|e| JacsError::Internal {
message: format!("Failed to acquire agent lock: {}", e),
})?;
let value = agent
.get_value()
.cloned()
.ok_or(JacsError::AgentNotLoaded)?;
serde_json::to_string_pretty(&value).map_err(|e| JacsError::Internal {
message: format!("Failed to serialize agent: {}", e),
})
}
#[must_use = "public key data must be used"]
pub fn get_public_key_pem(&self) -> Result<String, JacsError> {
let key_path = "./jacs_keys/jacs.public.pem";
fs::read_to_string(key_path).map_err(|e| {
let reason = match e.kind() {
std::io::ErrorKind::NotFound => {
"file does not exist. Run agent creation to generate keys first.".to_string()
}
std::io::ErrorKind::PermissionDenied => {
"permission denied. Check that the key file is readable.".to_string()
}
_ => e.to_string(),
};
JacsError::FileReadFailed {
path: key_path.to_string(),
reason,
}
})
}
pub fn diagnostics(&self) -> serde_json::Value {
let mut info = diagnostics();
if let Ok(agent) = self.agent.lock() {
if agent.ready() {
info["agent_loaded"] = serde_json::json!(true);
if let Some(value) = agent.get_value() {
info["agent_id"] =
serde_json::json!(value.get("jacsId").and_then(|v| v.as_str()));
info["agent_version"] =
serde_json::json!(value.get("jacsVersion").and_then(|v| v.as_str()));
}
}
if let Some(config) = &agent.config {
if let Some(dir) = config.jacs_data_directory().as_ref() {
info["data_directory"] = serde_json::json!(dir);
}
if let Some(dir) = config.jacs_key_directory().as_ref() {
info["key_directory"] = serde_json::json!(dir);
}
if let Some(storage) = config.jacs_default_storage().as_ref() {
info["default_storage"] = serde_json::json!(storage);
}
if let Some(algo) = config.jacs_agent_key_algorithm().as_ref() {
info["key_algorithm"] = serde_json::json!(algo);
}
}
}
info
}
pub fn config_path(&self) -> Option<&str> {
self.config_path.as_deref()
}
pub fn get_setup_instructions(
&self,
domain: &str,
ttl: u32,
) -> Result<SetupInstructions, JacsError> {
use crate::dns::bootstrap::{
DigestEncoding, build_dns_record, dnssec_guidance, emit_azure_cli, emit_cloudflare_curl,
emit_gcloud_dns, emit_plain_bind, emit_route53_change_batch, tld_requirement_text,
};
let agent = self.agent.lock().map_err(|e| JacsError::Internal {
message: format!("Failed to lock agent: {}", e),
})?;
let agent_value = agent.get_value().cloned().unwrap_or(json!({}));
let agent_id = agent_value.get_str_or("jacsId", "");
if agent_id.is_empty() {
return Err(JacsError::AgentNotLoaded);
}
let pk = agent.get_public_key().map_err(|e| JacsError::Internal {
message: format!("Failed to get public key: {}", e),
})?;
let digest = crate::dns::bootstrap::pubkey_digest_b64(&pk);
let rr = build_dns_record(domain, ttl, &agent_id, &digest, DigestEncoding::Base64);
let dns_record_bind = emit_plain_bind(&rr);
let dns_record_value = rr.txt.clone();
let dns_owner = rr.owner.clone();
let mut provider_commands = std::collections::HashMap::new();
provider_commands.insert("bind".to_string(), dns_record_bind.clone());
provider_commands.insert("route53".to_string(), emit_route53_change_batch(&rr));
provider_commands.insert(
"gcloud".to_string(),
emit_gcloud_dns(&rr, "YOUR_ZONE_NAME"),
);
provider_commands.insert(
"azure".to_string(),
emit_azure_cli(&rr, "YOUR_RG", domain, "_v1.agent.jacs"),
);
provider_commands.insert(
"cloudflare".to_string(),
emit_cloudflare_curl(&rr, "YOUR_ZONE_ID"),
);
let mut dnssec_instructions = std::collections::HashMap::new();
for name in &["aws", "cloudflare", "azure", "gcloud"] {
dnssec_instructions.insert(name.to_string(), dnssec_guidance(name).to_string());
}
let tld_requirement = tld_requirement_text().to_string();
let well_known = json!({
"jacs_agent_id": agent_id,
"jacs_public_key_hash": digest,
"jacs_dns_record": dns_owner,
});
let well_known_json =
serde_json::to_string_pretty(&well_known).unwrap_or_default();
let hai_url = std::env::var("HAI_API_URL")
.unwrap_or_else(|_| "https://api.hai.ai".to_string());
let hai_registration_url = format!("{}/v1/agents", hai_url.trim_end_matches('/'));
let hai_payload = json!({
"agent_id": agent_id,
"public_key_hash": digest,
"domain": domain,
});
let hai_registration_payload =
serde_json::to_string_pretty(&hai_payload).unwrap_or_default();
let hai_registration_instructions = format!(
"POST the payload to {} with your HAI API key in the Authorization header.",
hai_registration_url
);
let summary = format!(
"Setup instructions for agent {agent_id} on domain {domain}:\n\
\n\
1. DNS: Publish the following TXT record:\n\
{bind}\n\
\n\
2. DNSSEC: {dnssec}\n\
\n\
3. Domain requirement: {tld}\n\
\n\
4. .well-known: Serve the well-known JSON at /.well-known/jacs-agent.json\n\
\n\
5. HAI registration: {hai_instr}",
agent_id = agent_id,
domain = domain,
bind = dns_record_bind,
dnssec = dnssec_guidance("aws"),
tld = tld_requirement,
hai_instr = hai_registration_instructions,
);
Ok(SetupInstructions {
dns_record_bind,
dns_record_value,
dns_owner,
provider_commands,
dnssec_instructions,
tld_requirement,
well_known_json,
hai_registration_url,
hai_registration_payload,
hai_registration_instructions,
summary,
})
}
#[must_use]
pub fn verify_batch(&self, documents: &[&str]) -> Vec<VerificationResult> {
documents
.iter()
.map(|doc| match self.verify(doc) {
Ok(result) => result,
Err(e) => VerificationResult::failure(e.to_string()),
})
.collect()
}
#[must_use = "agreement document must be used or stored"]
pub fn create_agreement(
&self,
document: &str,
agent_ids: &[String],
question: Option<&str>,
context: Option<&str>,
) -> Result<SignedDocument, JacsError> {
use crate::agent::agreement::Agreement;
debug!("create_agreement() called with {} signers", agent_ids.len());
check_document_size(document)?;
let mut agent = self.agent.lock().map_err(|e| JacsError::Internal {
message: format!("Failed to acquire agent lock: {}", e),
})?;
let jacs_doc = agent
.create_document_and_load(document, None, None)
.map_err(|e| JacsError::SigningFailed {
reason: format!("Failed to create base document: {}", e),
})?;
let agreement_doc = agent
.create_agreement(&jacs_doc.getkey(), agent_ids, question, context, None)
.map_err(|e| JacsError::Internal {
message: format!("Failed to create agreement: {}", e),
})?;
let raw = serde_json::to_string(&agreement_doc.value).map_err(|e| JacsError::Internal {
message: format!("Failed to serialize agreement: {}", e),
})?;
let timestamp = agreement_doc
.value
.get_path_str_or(&["jacsSignature", "date"], "");
let agent_id = agreement_doc
.value
.get_path_str_or(&["jacsSignature", "agentID"], "");
info!("Agreement created: document_id={}", agreement_doc.id);
Ok(SignedDocument {
raw,
document_id: agreement_doc.id,
agent_id,
timestamp,
})
}
#[must_use = "signed agreement must be used or stored"]
pub fn sign_agreement(&self, document: &str) -> Result<SignedDocument, JacsError> {
use crate::agent::agreement::Agreement;
check_document_size(document)?;
let mut agent = self.agent.lock().map_err(|e| JacsError::Internal {
message: format!("Failed to acquire agent lock: {}", e),
})?;
let jacs_doc = agent
.load_document(document)
.map_err(|e| JacsError::DocumentMalformed {
field: "document".to_string(),
reason: e.to_string(),
})?;
let signed_doc = agent
.sign_agreement(&jacs_doc.getkey(), None)
.map_err(|e| JacsError::SigningFailed {
reason: format!("Failed to sign agreement: {}", e),
})?;
let raw = serde_json::to_string(&signed_doc.value).map_err(|e| JacsError::Internal {
message: format!("Failed to serialize signed agreement: {}", e),
})?;
let timestamp = signed_doc
.value
.get_path_str_or(&["jacsSignature", "date"], "");
let agent_id = signed_doc
.value
.get_path_str_or(&["jacsSignature", "agentID"], "");
Ok(SignedDocument {
raw,
document_id: signed_doc.id,
agent_id,
timestamp,
})
}
#[must_use = "agreement status must be checked"]
pub fn check_agreement(&self, document: &str) -> Result<AgreementStatus, JacsError> {
check_document_size(document)?;
let mut agent = self.agent.lock().map_err(|e| JacsError::Internal {
message: format!("Failed to acquire agent lock: {}", e),
})?;
let jacs_doc = agent
.load_document(document)
.map_err(|e| JacsError::DocumentMalformed {
field: "document".to_string(),
reason: e.to_string(),
})?;
let unsigned =
jacs_doc
.agreement_unsigned_agents(None)
.map_err(|e| JacsError::Internal {
message: format!("Failed to check unsigned agents: {}", e),
})?;
let all_agents =
jacs_doc
.agreement_requested_agents(None)
.map_err(|e| JacsError::Internal {
message: format!("Failed to get agreement agents: {}", e),
})?;
let mut signers = Vec::new();
let unsigned_set: std::collections::HashSet<&String> = unsigned.iter().collect();
for agent_id in &all_agents {
let signed = !unsigned_set.contains(agent_id);
signers.push(SignerStatus {
agent_id: agent_id.clone(),
signed,
signed_at: if signed {
Some(
jacs_doc
.value
.get_path_str_or(&["jacsSignature", "date"], "")
.to_string(),
)
} else {
None
},
});
}
Ok(AgreementStatus {
complete: unsigned.is_empty(),
signers,
pending: unsigned,
})
}
#[cfg(not(target_arch = "wasm32"))]
pub fn register_with_hai(
&self,
api_key: Option<&str>,
hai_url: &str,
preview: bool,
) -> Result<RegistrationInfo, Box<dyn std::error::Error>> {
if preview {
return Ok(RegistrationInfo {
hai_registered: false,
hai_error: "preview mode".to_string(),
dns_record: String::new(),
dns_route53: String::new(),
});
}
let key = match api_key {
Some(k) => k.to_string(),
None => std::env::var("HAI_API_KEY").map_err(|_| {
"No API key provided and HAI_API_KEY environment variable not set"
})?,
};
let agent_json = self.export_agent()?;
let url = format!(
"{}/api/v1/agents/register",
hai_url.trim_end_matches('/')
);
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", key))
.header("Content-Type", "application/json")
.json(&serde_json::json!({ "agent_json": agent_json }))
.send()?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().unwrap_or_default();
return Ok(RegistrationInfo {
hai_registered: false,
hai_error: format!("HTTP {}: {}", status, body),
dns_record: String::new(),
dns_route53: String::new(),
});
}
let body: Value = response.json()?;
Ok(RegistrationInfo {
hai_registered: true,
hai_error: String::new(),
dns_record: body
.get("dns_record")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
dns_route53: body
.get("dns_route53")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
})
}
}
use std::cell::RefCell;
thread_local! {
static THREAD_AGENT: RefCell<Option<SimpleAgent>> = const { RefCell::new(None) };
}
#[deprecated(since = "0.3.0", note = "Use SimpleAgent::create() instead")]
pub fn create(
name: &str,
purpose: Option<&str>,
key_algorithm: Option<&str>,
) -> Result<AgentInfo, JacsError> {
let (agent, info) = SimpleAgent::create(name, purpose, key_algorithm)?;
THREAD_AGENT.with(|cell| {
*cell.borrow_mut() = Some(agent);
});
Ok(info)
}
#[deprecated(
since = "0.6.0",
note = "Use SimpleAgent::create_with_params() instead"
)]
pub fn create_with_params(params: CreateAgentParams) -> Result<AgentInfo, JacsError> {
let (agent, info) = SimpleAgent::create_with_params(params)?;
THREAD_AGENT.with(|cell| {
*cell.borrow_mut() = Some(agent);
});
Ok(info)
}
#[deprecated(since = "0.3.0", note = "Use SimpleAgent::load() instead")]
pub fn load(config_path: Option<&str>) -> Result<(), JacsError> {
let agent = SimpleAgent::load(config_path)?;
THREAD_AGENT.with(|cell| {
*cell.borrow_mut() = Some(agent);
});
Ok(())
}
fn with_thread_agent<F, T>(f: F) -> Result<T, JacsError>
where
F: FnOnce(&SimpleAgent) -> Result<T, JacsError>,
{
THREAD_AGENT.with(|cell| {
let borrow = cell.borrow();
let agent = borrow.as_ref().ok_or(JacsError::AgentNotLoaded)?;
f(agent)
})
}
#[deprecated(since = "0.3.0", note = "Use SimpleAgent::verify_self() instead")]
pub fn verify_self() -> Result<VerificationResult, JacsError> {
with_thread_agent(|agent| agent.verify_self())
}
#[deprecated(since = "0.3.0", note = "Use SimpleAgent::update_agent() instead")]
pub fn update_agent(new_agent_data: &str) -> Result<String, JacsError> {
with_thread_agent(|agent| agent.update_agent(new_agent_data))
}
#[deprecated(since = "0.3.0", note = "Use SimpleAgent::update_document() instead")]
pub fn update_document(
document_id: &str,
new_data: &str,
attachments: Option<Vec<String>>,
embed: Option<bool>,
) -> Result<SignedDocument, JacsError> {
with_thread_agent(|agent| agent.update_document(document_id, new_data, attachments, embed))
}
#[deprecated(since = "0.3.0", note = "Use SimpleAgent::sign_message() instead")]
pub fn sign_message(data: &Value) -> Result<SignedDocument, JacsError> {
with_thread_agent(|agent| agent.sign_message(data))
}
#[deprecated(since = "0.3.0", note = "Use SimpleAgent::sign_file() instead")]
pub fn sign_file(file_path: &str, embed: bool) -> Result<SignedDocument, JacsError> {
with_thread_agent(|agent| agent.sign_file(file_path, embed))
}
#[deprecated(since = "0.3.0", note = "Use SimpleAgent::verify() instead")]
pub fn verify(signed_document: &str) -> Result<VerificationResult, JacsError> {
with_thread_agent(|agent| agent.verify(signed_document))
}
#[deprecated(since = "0.6.0", note = "Use SimpleAgent::verify_by_id() instead")]
pub fn verify_by_id(document_id: &str) -> Result<VerificationResult, JacsError> {
with_thread_agent(|agent| agent.verify_by_id(document_id))
}
#[deprecated(since = "0.3.0", note = "Use SimpleAgent::export_agent() instead")]
pub fn export_agent() -> Result<String, JacsError> {
with_thread_agent(|agent| agent.export_agent())
}
#[deprecated(
since = "0.3.0",
note = "Use SimpleAgent::get_public_key_pem() instead"
)]
pub fn get_public_key_pem() -> Result<String, JacsError> {
with_thread_agent(|agent| agent.get_public_key_pem())
}
#[deprecated(since = "0.3.0", note = "Use SimpleAgent::create_agreement() instead")]
pub fn create_agreement(
document: &str,
agent_ids: &[String],
question: Option<&str>,
context: Option<&str>,
) -> Result<SignedDocument, JacsError> {
with_thread_agent(|agent| agent.create_agreement(document, agent_ids, question, context))
}
#[deprecated(since = "0.3.0", note = "Use SimpleAgent::sign_agreement() instead")]
pub fn sign_agreement(document: &str) -> Result<SignedDocument, JacsError> {
with_thread_agent(|agent| agent.sign_agreement(document))
}
#[deprecated(since = "0.3.0", note = "Use SimpleAgent::check_agreement() instead")]
pub fn check_agreement(document: &str) -> Result<AgreementStatus, JacsError> {
with_thread_agent(|agent| agent.check_agreement(document))
}
fn extract_attachments(doc: &Value) -> Vec<Attachment> {
let mut attachments = Vec::new();
if let Some(files) = doc.get("jacsFiles").and_then(|f| f.as_array()) {
for file in files {
let filename = file["path"].as_str().unwrap_or("unknown").to_string();
let mime_type = file["mimetype"]
.as_str()
.unwrap_or("application/octet-stream")
.to_string();
let hash = file["sha256"].as_str().unwrap_or("").to_string();
let embedded = file["embed"].as_bool().unwrap_or(false);
let content = if embedded {
if let Some(contents_b64) = file["contents"].as_str() {
use base64::{Engine as _, engine::general_purpose::STANDARD};
STANDARD.decode(contents_b64).unwrap_or_default()
} else {
Vec::new()
}
} else {
Vec::new()
};
attachments.push(Attachment {
filename,
mime_type,
content,
hash,
embedded,
});
}
}
attachments
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_diagnostics_returns_version() {
let info = diagnostics();
let version = info["jacs_version"].as_str().unwrap();
assert!(!version.is_empty(), "jacs_version should not be empty");
assert_eq!(info["agent_loaded"], false);
assert!(info["os"].as_str().is_some());
assert!(info["arch"].as_str().is_some());
}
#[test]
fn test_agent_info_serialization() {
let info = AgentInfo {
agent_id: "test-id".to_string(),
name: "Test Agent".to_string(),
public_key_path: "./keys/public.pem".to_string(),
config_path: "./config.json".to_string(),
version: "v1".to_string(),
algorithm: "pq2025".to_string(),
private_key_path: "./keys/private.pem.enc".to_string(),
data_directory: "./data".to_string(),
key_directory: "./keys".to_string(),
domain: String::new(),
dns_record: String::new(),
hai_registered: false,
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("test-id"));
assert!(json.contains("Test Agent"));
assert!(json.contains("pq2025"));
}
#[test]
fn test_create_agent_params_defaults() {
let params = CreateAgentParams::default();
assert_eq!(params.algorithm, "pq2025");
assert_eq!(params.data_directory, "./jacs_data");
assert_eq!(params.key_directory, "./jacs_keys");
assert_eq!(params.config_path, "./jacs.config.json");
assert_eq!(params.agent_type, "ai");
assert_eq!(params.default_storage, "fs");
}
#[test]
fn test_create_agent_params_builder() {
let params = CreateAgentParams::builder()
.name("test-agent")
.password("test-pass")
.algorithm("ring-Ed25519")
.data_directory("/tmp/data")
.key_directory("/tmp/keys")
.build();
assert_eq!(params.name, "test-agent");
assert_eq!(params.password, "test-pass");
assert_eq!(params.algorithm, "ring-Ed25519");
assert_eq!(params.data_directory, "/tmp/data");
assert_eq!(params.key_directory, "/tmp/keys");
}
#[test]
fn test_verification_result_serialization() {
let result = VerificationResult {
valid: true,
data: json!({"test": "data"}),
signer_id: "agent-123".to_string(),
signer_name: Some("Test Agent".to_string()),
timestamp: "2024-01-01T00:00:00Z".to_string(),
attachments: vec![],
errors: vec![],
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"valid\":true"));
assert!(json.contains("agent-123"));
}
#[test]
fn test_signed_document_serialization() {
let doc = SignedDocument {
raw: r#"{"test":"doc"}"#.to_string(),
document_id: "doc-456".to_string(),
agent_id: "agent-789".to_string(),
timestamp: "2024-01-01T12:00:00Z".to_string(),
};
let json = serde_json::to_string(&doc).unwrap();
assert!(json.contains("doc-456"));
assert!(json.contains("agent-789"));
}
#[test]
fn test_attachment_serialization() {
let att = Attachment {
filename: "test.txt".to_string(),
mime_type: "text/plain".to_string(),
content: b"hello world".to_vec(),
hash: "abc123".to_string(),
embedded: true,
};
let json = serde_json::to_string(&att).unwrap();
assert!(json.contains("test.txt"));
assert!(json.contains("text/plain"));
assert!(json.contains("abc123"));
}
#[test]
fn test_thread_agent_not_loaded() {
THREAD_AGENT.with(|cell| {
*cell.borrow_mut() = None;
});
#[allow(deprecated)]
let result = verify_self();
assert!(result.is_err());
match result {
Err(JacsError::AgentNotLoaded) => (),
_ => panic!("Expected AgentNotLoaded error"),
}
}
#[test]
fn test_simple_agent_load_missing_config() {
let result = SimpleAgent::load(Some("/nonexistent/path/config.json"));
assert!(result.is_err());
match result {
Err(JacsError::ConfigNotFound { path }) => {
assert!(path.contains("nonexistent"));
}
_ => panic!("Expected ConfigNotFound error"),
}
}
#[test]
#[allow(deprecated)]
fn test_deprecated_load_missing_config() {
let result = load(Some("/nonexistent/path/config.json"));
assert!(result.is_err());
match result {
Err(JacsError::ConfigNotFound { path }) => {
assert!(path.contains("nonexistent"));
}
_ => panic!("Expected ConfigNotFound error"),
}
}
#[test]
#[allow(deprecated)]
fn test_sign_file_missing_file() {
THREAD_AGENT.with(|cell| {
*cell.borrow_mut() = None;
});
let result = sign_file("/nonexistent/file.txt", false);
assert!(result.is_err());
}
#[test]
fn test_verification_result_with_errors() {
let result = VerificationResult {
valid: false,
data: json!(null),
signer_id: "".to_string(),
signer_name: None,
timestamp: "".to_string(),
attachments: vec![],
errors: vec!["Signature invalid".to_string(), "Hash mismatch".to_string()],
};
assert!(!result.valid);
assert_eq!(result.errors.len(), 2);
assert!(result.errors[0].contains("Signature"));
assert!(result.errors[1].contains("Hash"));
}
#[test]
fn test_extract_attachments_empty() {
let doc = json!({});
let attachments = extract_attachments(&doc);
assert!(attachments.is_empty());
}
#[test]
fn test_extract_attachments_with_files() {
let doc = json!({
"jacsFiles": [
{
"path": "document.pdf",
"mimetype": "application/pdf",
"sha256": "abcdef123456",
"embed": false
},
{
"path": "image.png",
"mimetype": "image/png",
"sha256": "fedcba654321",
"embed": true,
"contents": "SGVsbG8gV29ybGQ="
}
]
});
let attachments = extract_attachments(&doc);
assert_eq!(attachments.len(), 2);
assert_eq!(attachments[0].filename, "document.pdf");
assert_eq!(attachments[0].mime_type, "application/pdf");
assert!(!attachments[0].embedded);
assert!(attachments[0].content.is_empty());
assert_eq!(attachments[1].filename, "image.png");
assert_eq!(attachments[1].mime_type, "image/png");
assert!(attachments[1].embedded);
assert!(!attachments[1].content.is_empty());
}
#[test]
#[allow(deprecated)]
fn test_get_public_key_pem_not_found() {
THREAD_AGENT.with(|cell| {
*cell.borrow_mut() = None;
});
let result = get_public_key_pem();
assert!(result.is_err());
}
#[test]
fn test_simple_agent_struct_has_config_path() {
let result = SimpleAgent::load(Some("./nonexistent.json"));
assert!(result.is_err());
}
#[test]
fn test_verification_result_failure_constructor() {
let result = VerificationResult::failure("Test error message".to_string());
assert!(!result.valid);
assert_eq!(result.errors.len(), 1);
assert!(result.errors[0].contains("Test error message"));
assert_eq!(result.signer_id, "");
assert!(result.signer_name.is_none());
}
#[test]
fn test_verification_result_success_constructor() {
let data = json!({"message": "hello"});
let signer_id = "agent-123".to_string();
let timestamp = "2024-01-15T10:30:00Z".to_string();
let result =
VerificationResult::success(data.clone(), signer_id.clone(), timestamp.clone());
assert!(result.valid);
assert_eq!(result.data, data);
assert_eq!(result.signer_id, signer_id);
assert!(result.signer_name.is_none());
assert_eq!(result.timestamp, timestamp);
assert!(result.attachments.is_empty());
assert!(result.errors.is_empty());
}
#[test]
fn test_verification_result_failure_has_null_data() {
let result = VerificationResult::failure("error".to_string());
assert_eq!(result.data, json!(null));
assert!(result.timestamp.is_empty());
assert!(result.attachments.is_empty());
}
#[test]
fn test_verify_non_json_returns_helpful_error() {
let agent = SimpleAgent {
agent: Mutex::new(crate::get_empty_agent()),
config_path: None,
};
let result = agent.verify("not-json-at-all");
assert!(result.is_err());
let err = result.unwrap_err();
let err_str = err.to_string();
assert!(
err_str.contains("verify_by_id"),
"Error should suggest verify_by_id(): {}",
err_str
);
}
#[test]
fn test_verify_uuid_like_input_returns_helpful_error() {
let agent = SimpleAgent {
agent: Mutex::new(crate::get_empty_agent()),
config_path: None,
};
let result = agent.verify("550e8400-e29b-41d4-a716-446655440000:1");
assert!(result.is_err());
let err = result.unwrap_err();
let err_str = err.to_string();
assert!(
err_str.contains("verify_by_id"),
"Error for UUID-like input should suggest verify_by_id(): {}",
err_str
);
}
#[test]
fn test_verify_empty_string_returns_error() {
let agent = SimpleAgent {
agent: Mutex::new(crate::get_empty_agent()),
config_path: None,
};
let result = agent.verify("");
assert!(result.is_err());
}
#[test]
fn test_register_with_hai_preview() {
let agent = SimpleAgent {
agent: Mutex::new(crate::get_empty_agent()),
config_path: None,
};
let result = agent
.register_with_hai(None, "https://hai.ai", true)
.expect("preview should succeed");
assert!(!result.hai_registered);
assert_eq!(result.hai_error, "preview mode");
assert!(result.dns_record.is_empty());
assert!(result.dns_route53.is_empty());
}
#[test]
fn test_setup_instructions_serialization() {
let instr = SetupInstructions {
dns_record_bind: "example.com. 3600 IN TXT \"test\"".to_string(),
dns_record_value: "test".to_string(),
dns_owner: "_v1.agent.jacs.example.com.".to_string(),
provider_commands: std::collections::HashMap::new(),
dnssec_instructions: std::collections::HashMap::new(),
tld_requirement: "You must own a domain".to_string(),
well_known_json: "{}".to_string(),
hai_registration_url: "https://api.hai.ai/v1/agents".to_string(),
hai_registration_payload: "{}".to_string(),
hai_registration_instructions: "POST to the URL".to_string(),
summary: "Setup summary".to_string(),
};
let json = serde_json::to_string(&instr).unwrap();
assert!(json.contains("dns_record_bind"));
assert!(json.contains("_v1.agent.jacs.example.com."));
assert!(json.contains("hai_registration_url"));
}
#[test]
fn test_get_setup_instructions_requires_loaded_agent() {
let agent = SimpleAgent {
agent: Mutex::new(crate::get_empty_agent()),
config_path: None,
};
let result = agent.get_setup_instructions("example.com", 3600);
assert!(result.is_err(), "should fail without a loaded agent");
}
}