use crate::CoreError;
use jsonschema::Retrieve;
use phf::phf_map;
use serde_json::Value;
use std::collections::HashSet;
use std::error::Error;
pub const V2_SCHEMA_ID: &str = "https://hai.ai/schemas/agreement/v2/agreement.schema.json";
pub static DEFAULT_SCHEMA_STRINGS: phf::Map<&'static str, &'static str> = phf_map! {
"schemas/agent/v1/agent.schema.json" => include_str!("../schemas/agent/v1/agent.schema.json"),
"schemas/header/v1/header.schema.json" => include_str!("../schemas/header/v1/header.schema.json"),
"schemas/components/signature/v1/signature.schema.json" => include_str!("../schemas/components/signature/v1/signature.schema.json"),
"schemas/components/files/v1/files.schema.json" => include_str!("../schemas/components/files/v1/files.schema.json"),
"schemas/components/agreement/v1/agreement.schema.json" => include_str!("../schemas/components/agreement/v1/agreement.schema.json"),
"schemas/agreement/v2/agreement.schema.json" => include_str!("../schemas/agreement/v2/agreement.schema.json"),
"schemas/attestation/v1/attestation.schema.json" => include_str!("../schemas/attestation/v1/attestation.schema.json"),
"schemas/conflict/v1/conflict.schema.json" => include_str!("../schemas/conflict/v1/conflict.schema.json"),
};
pub static SCHEMA_SHORT_NAME: phf::Map<&'static str, &'static str> = phf_map! {
"https://hai.ai/schemas/agent/v1/agent.schema.json" => "agent",
"https://hai.ai/schemas/components/agreement/v1/agreement.schema.json" => "agreement",
"https://hai.ai/schemas/agreement/v2/agreement.schema.json" => "agreement",
"https://hai.ai/schemas/components/files/v1/files.schema.json" => "files",
"https://hai.ai/schemas/components/signature/v1/signature.schema.json" => "signature",
"https://hai.ai/schemas/header/v1/header.schema.json" => "header",
"document" => "document",
"https://hai.ai/schemas/attestation/v1/attestation.schema.json" => "attestation",
"https://hai.ai/schemas/conflict/v1/conflict.schema.json" => "conflict",
};
pub static CONFIG_SCHEMA_STRING: &str = include_str!("../schemas/jacs.config.schema.json");
#[derive(Debug, Default, Clone, Copy)]
pub struct EmbeddedSchemaResolver;
impl EmbeddedSchemaResolver {
pub fn new() -> Self {
EmbeddedSchemaResolver
}
pub fn lookup(path: &str) -> Option<&'static str> {
let trimmed = path.strip_prefix('/').unwrap_or(path);
DEFAULT_SCHEMA_STRINGS.get(trimmed).copied()
}
pub fn resolve(path: &str) -> Result<Value, CoreError> {
let body = Self::lookup(path)
.ok_or_else(|| CoreError::SchemaInvalid(format!("unknown schema $ref '{path}'")))?;
serde_json::from_str(body)
.map_err(|e| CoreError::SchemaInvalid(format!("failed to parse schema '{path}': {e}")))
}
}
impl Retrieve for EmbeddedSchemaResolver {
fn retrieve(
&self,
uri: &jsonschema::Uri<String>,
) -> Result<Value, Box<dyn Error + Send + Sync>> {
Self::resolve(uri.path().as_str()).map_err(|e| {
Box::new(e) as Box<dyn Error + Send + Sync>
})
}
}
pub fn validate_agreement_v2_document(document: &Value) -> Result<(), CoreError> {
let object = document.as_object().ok_or_else(|| {
CoreError::SchemaInvalid("agreement v2 document must be an object".into())
})?;
if let Some(schema_id) = object.get("$schema")
&& schema_id.as_str() != Some(V2_SCHEMA_ID)
{
return Err(CoreError::SchemaInvalid(format!(
"agreement v2 document declares unsupported $schema '{}'",
schema_id.as_str().unwrap_or("<non-string>")
)));
}
if object.get("jacsType").and_then(Value::as_str) != Some("agreement") {
return Err(CoreError::SchemaInvalid(
"agreement v2 document must declare jacsType 'agreement'".into(),
));
}
if let Some(level) = object.get("jacsLevel")
&& level.as_str() != Some("artifact")
{
return Err(CoreError::SchemaInvalid(
"agreement v2 document must declare jacsLevel 'artifact'".into(),
));
}
let agreement_schema = embedded_schema_value("schemas/agreement/v2/agreement.schema.json")?;
let validator = jsonschema::Validator::options()
.with_draft(jsonschema::Draft::Draft7)
.with_retriever(EmbeddedSchemaResolver::new())
.should_validate_formats(true)
.build(&agreement_schema)
.map_err(|err| {
CoreError::SchemaInvalid(format!("failed to compile agreement v2 schema: {err}"))
})?;
let schema_check_doc = normalize_signing_algorithm_for_schema(document);
validator.validate(&schema_check_doc).map_err(|err| {
CoreError::SchemaInvalid(format!(
"agreement v2 schema validation failed at '{}': {}",
err.instance_path, err
))
})?;
validate_agreement_v2_root_fields(document, &agreement_schema)?;
validate_agreement_v2_uuids(document)?;
Ok(())
}
fn normalize_signing_algorithm_for_schema(document: &Value) -> Value {
fn walk(value: &mut Value) {
match value {
Value::Object(map) => {
if let Some(Value::String(algo)) = map.get_mut("signingAlgorithm")
&& algo == "ed25519"
{
*algo = "ring-Ed25519".to_string();
}
for (_k, v) in map.iter_mut() {
walk(v);
}
}
Value::Array(items) => {
for item in items.iter_mut() {
walk(item);
}
}
_ => {}
}
}
let mut cloned = document.clone();
walk(&mut cloned);
cloned
}
fn embedded_schema_value(path: &str) -> Result<Value, CoreError> {
let body = DEFAULT_SCHEMA_STRINGS
.get(path)
.copied()
.ok_or_else(|| CoreError::SchemaInvalid(format!("missing embedded schema '{path}'")))?;
serde_json::from_str(body)
.map_err(|err| CoreError::SchemaInvalid(format!("failed to parse schema '{path}': {err}")))
}
fn validate_agreement_v2_root_fields(
document: &Value,
agreement_schema: &Value,
) -> Result<(), CoreError> {
let mut allowed = HashSet::new();
allowed.insert("$schema".to_string());
allowed.insert("$id".to_string());
for branch in agreement_schema
.get("allOf")
.and_then(Value::as_array)
.ok_or_else(|| CoreError::SchemaInvalid("agreement v2 schema missing allOf".into()))?
{
if let Some(properties) = branch.get("properties").and_then(Value::as_object) {
allowed.extend(properties.keys().cloned());
}
}
let header_schema = embedded_schema_value("schemas/header/v1/header.schema.json")?;
let header_properties = header_schema
.get("properties")
.and_then(Value::as_object)
.ok_or_else(|| CoreError::SchemaInvalid("header schema missing properties".into()))?;
allowed.extend(header_properties.keys().cloned());
let object = document.as_object().ok_or_else(|| {
CoreError::MalformedDocument("agreement v2 document must be an object".into())
})?;
for key in object.keys() {
if !allowed.contains(key) {
return Err(CoreError::MalformedDocument(format!(
"unknown top-level field '{}' is not permitted on agreement v2 documents",
key
)));
}
}
Ok(())
}
fn validate_agreement_v2_uuids(document: &Value) -> Result<(), CoreError> {
validate_uuid_field(document, "jacsId")?;
validate_uuid_array(document, "controllers")?;
validate_uuid_array(document, "owners")?;
validate_uuid_array(document, "allPreviousVersions")?;
if let Some(parties) = document.get("parties").and_then(Value::as_array) {
for (index, party) in parties.iter().enumerate() {
validate_uuid_named_field(party, "agentId", &format!("parties[{index}].agentId"))?;
validate_uuid_named_field(
party,
"agentVersion",
&format!("parties[{index}].agentVersion"),
)?;
}
}
Ok(())
}
fn validate_uuid_array(document: &Value, field: &str) -> Result<(), CoreError> {
if let Some(values) = document.get(field).and_then(Value::as_array) {
for (index, value) in values.iter().enumerate() {
validate_uuid_value(value, &format!("{field}[{index}]"))?;
}
}
Ok(())
}
fn validate_uuid_field(document: &Value, field: &str) -> Result<(), CoreError> {
validate_uuid_named_field(document, field, field)
}
fn validate_uuid_named_field(
document: &Value,
json_key: &str,
display_field: &str,
) -> Result<(), CoreError> {
if let Some(value) = document.get(json_key) {
validate_uuid_value(value, display_field)?;
}
Ok(())
}
fn validate_uuid_value(value: &Value, field: &str) -> Result<(), CoreError> {
if let Some(value) = value.as_str()
&& uuid::Uuid::parse_str(value).is_err()
{
return Err(CoreError::MalformedDocument(format!(
"field '{}' must be a valid UUID",
field
)));
}
Ok(())
}