use crate::act::core::types::{Error as ToolError, LocalizedString};
const INVALID_ARGS: &str = "std:invalid-args";
pub struct Validator {
schema: Option<boon::Schemas>,
index: boon::SchemaIndex,
}
impl Validator {
pub fn compile(what: &str, schema_text: &str) -> Option<Self> {
let value: serde_json::Value = match serde_json::from_str(schema_text) {
Ok(v) => v,
Err(e) => {
tracing::warn!(%what, error = %e, "schema is not JSON; arguments will not be validated");
return None;
}
};
let mut schemas = boon::Schemas::new();
let mut compiler = boon::Compiler::new();
let url = "act:///schema";
if let Err(e) = compiler.add_resource(url, value) {
tracing::warn!(%what, error = %e, "schema could not be added; arguments will not be validated");
return None;
}
match compiler.compile(url, &mut schemas) {
Ok(index) => Some(Self {
schema: Some(schemas),
index,
}),
Err(e) => {
tracing::warn!(%what, error = %e, "schema did not compile; arguments will not be validated");
None
}
}
}
pub fn check(&self, value: &serde_json::Value) -> Result<(), String> {
let Some(schemas) = &self.schema else {
return Ok(());
};
schemas.validate(value, self.index).map_err(|e| {
e.to_string()
})
}
}
pub fn invalid_args(message: String) -> ToolError {
ToolError {
kind: INVALID_ARGS.to_string(),
message: LocalizedString::Plain(message),
metadata: Vec::new(),
}
}
pub fn arguments_as_json(arguments: &[u8]) -> Result<serde_json::Value, String> {
if arguments.is_empty() {
return Ok(serde_json::Value::Object(serde_json::Map::new()));
}
act_types::cbor::cbor_to_json(arguments)
.map_err(|e| format!("arguments are not decodable CBOR: {e}"))
}
pub fn session_args_as_json(args: &[(String, Vec<u8>)]) -> Result<serde_json::Value, String> {
let mut map = serde_json::Map::with_capacity(args.len());
for (name, value) in args {
let decoded = act_types::cbor::cbor_to_json(value)
.map_err(|e| format!("session argument '{name}' is not decodable CBOR: {e}"))?;
map.insert(name.clone(), decoded);
}
Ok(serde_json::Value::Object(map))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const SCHEMA: &str = r#"{
"type": "object",
"properties": {
"path": { "type": "string" },
"limit": { "type": "integer" }
},
"required": ["path"]
}"#;
#[test]
fn arguments_matching_the_schema_pass() {
let v = Validator::compile("read", SCHEMA).expect("compiles");
assert!(v.check(&json!({"path": "/tmp/x", "limit": 3})).is_ok());
}
#[test]
fn a_missing_required_property_is_named() {
let v = Validator::compile("read", SCHEMA).expect("compiles");
let err = v.check(&json!({"limit": 3})).expect_err("must reject");
assert!(
err.contains("path"),
"an agent has to learn which property to add: {err}"
);
}
#[test]
fn a_wrong_type_is_named() {
let v = Validator::compile("read", SCHEMA).expect("compiles");
let err = v
.check(&json!({"path": "/tmp/x", "limit": "three"}))
.expect_err("must reject");
assert!(err.contains("limit"), "{err}");
}
#[test]
fn no_arguments_at_all_still_fails_a_required_property() {
let v = Validator::compile("read", SCHEMA).expect("compiles");
let value = arguments_as_json(&[]).expect("empty is an empty object");
assert_eq!(value, json!({}));
assert!(v.check(&value).is_err());
}
#[test]
fn a_schema_that_is_not_json_disables_validation_rather_than_failing() {
assert!(Validator::compile("broken", "not json at all").is_none());
}
#[test]
fn a_schema_that_does_not_compile_disables_validation() {
assert!(Validator::compile("broken", r#"{"type": 7}"#).is_none());
}
#[test]
fn a_remote_ref_does_not_resolve() {
let hostile = r#"{"$ref": "https://evil.example.com/schema.json"}"#;
assert!(
Validator::compile("hostile", hostile).is_none(),
"an external $ref must not be fetched, so it must not compile"
);
}
#[test]
fn undecodable_arguments_are_invalid_arguments() {
let err = arguments_as_json(&[0xff, 0xff, 0xff]).expect_err("not CBOR");
assert!(err.contains("CBOR"), "{err}");
}
#[test]
fn session_args_become_one_object_keyed_by_name() {
let args = vec![
(
"std:bearer-token".to_string(),
act_types::cbor::to_cbor(&"t"),
),
("acme:tenant".to_string(), act_types::cbor::to_cbor(&"42")),
];
let value = session_args_as_json(&args).expect("decodes");
assert_eq!(value["std:bearer-token"], "t");
assert_eq!(value["acme:tenant"], "42");
}
#[test]
fn the_error_is_shaped_like_a_guests_own() {
let e = invalid_args("nope".into());
assert_eq!(e.kind, INVALID_ARGS);
assert!(matches!(e.message, LocalizedString::Plain(ref m) if m == "nope"));
assert!(
e.metadata.is_empty(),
"a host-authored error carries no guest metadata"
);
}
}