use anyhow::{Result, bail};
use crate::models::{
Citation, CreateMemory, MAX_CONTENT_SIZE, MAX_NAMESPACE_DEPTH, Memory, SourceSpan,
UpdateMemory, VALID_AGENT_TYPES, VALID_SCOPES,
};
const MAX_TITLE_LEN: usize = 512;
const MAX_NAMESPACE_LEN: usize = 512;
const MAX_SOURCE_LEN: usize = 64;
const MAX_TAG_LEN: usize = 128;
const MAX_TAGS_COUNT: usize = 50;
const MAX_RELATION_LEN: usize = 64;
const MAX_ID_LEN: usize = 128;
const MAX_AGENT_ID_LEN: usize = 128;
const MAX_AGENT_PUBKEY_B64_LEN: usize = 128;
const MAX_METADATA_SIZE: usize = 65_536;
const MAX_METADATA_DEPTH: usize = 32;
pub(crate) const VALID_SOURCES: &[&str] = &[
"user",
"nhi",
"claude",
"hook",
"api",
"cli",
"import",
"consolidation",
"system",
"chaos",
"notify",
];
pub const DEFAULT_NHI_SOURCE: &str = "nhi";
const VALID_RELATIONS: &[&str] = &[
crate::models::MemoryLinkRelation::RelatedTo.as_str(),
crate::models::MemoryLinkRelation::Supersedes.as_str(),
crate::models::MemoryLinkRelation::Contradicts.as_str(),
crate::models::MemoryLinkRelation::DerivedFrom.as_str(),
crate::models::MemoryLinkRelation::ReflectsOn.as_str(),
crate::models::MemoryLinkRelation::DerivesFrom.as_str(),
];
fn is_valid_rfc3339(s: &str) -> bool {
chrono::DateTime::parse_from_rfc3339(s).is_ok()
}
fn is_clean_string(s: &str) -> bool {
!s.chars().any(|c| c.is_control() && c != '\n' && c != '\t')
}
pub fn validate_title(title: &str) -> Result<()> {
let trimmed = title.trim();
if trimmed.is_empty() {
bail!("title cannot be empty");
}
if trimmed.chars().count() > MAX_TITLE_LEN {
bail!("title exceeds max length of {MAX_TITLE_LEN} characters");
}
if !is_clean_string(trimmed) {
bail!("title contains invalid characters");
}
Ok(())
}
pub fn validate_content(content: &str) -> Result<()> {
if content.trim().is_empty() {
bail!("content cannot be empty");
}
if content.len() > MAX_CONTENT_SIZE {
bail!("content exceeds max size of {MAX_CONTENT_SIZE} bytes");
}
if !is_clean_string(content) {
bail!("content contains invalid characters");
}
Ok(())
}
pub fn validate_namespace(ns: &str) -> Result<()> {
let trimmed = ns.trim();
if trimmed.is_empty() {
bail!("namespace cannot be empty");
}
if trimmed.chars().count() > MAX_NAMESPACE_LEN {
bail!("namespace exceeds max length of {MAX_NAMESPACE_LEN} characters");
}
if trimmed.contains('\\') || trimmed.contains('\0') {
bail!("namespace cannot contain backslashes or null bytes");
}
if trimmed.contains(' ') {
bail!("namespace cannot contain spaces (use hyphens or underscores)");
}
if !is_clean_string(trimmed) {
bail!("namespace contains invalid control characters");
}
if trimmed.starts_with('/') {
bail!("namespace cannot start with '/' (normalize input first)");
}
if trimmed.ends_with('/') {
bail!("namespace cannot end with '/' (normalize input first)");
}
if trimmed.split('/').any(str::is_empty) {
bail!("namespace cannot contain empty segments (e.g. '//')");
}
if trimmed.split('/').any(|s| s == ".." || s == ".") {
bail!("namespace segments '.' and '..' are not allowed");
}
let depth = crate::models::namespace_depth(trimmed);
if depth > MAX_NAMESPACE_DEPTH {
bail!("namespace depth {depth} exceeds max of {MAX_NAMESPACE_DEPTH}");
}
Ok(())
}
#[allow(dead_code)]
#[must_use]
pub fn normalize_namespace(input: &str) -> String {
let trimmed = input.trim();
let collapsed: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
collapsed.join("/").to_lowercase()
}
pub fn validate_source(source: &str) -> Result<()> {
if source.trim().is_empty() {
bail!("source cannot be empty");
}
if source.len() > MAX_SOURCE_LEN {
bail!("source exceeds max length of {MAX_SOURCE_LEN} bytes");
}
if !VALID_SOURCES.contains(&source) {
bail!(
"invalid source '{}' — must be one of: {}",
source,
VALID_SOURCES.join(", ")
);
}
Ok(())
}
pub const RESERVED_AGENT_IDS: &[&str] = &[
crate::identity::sentinels::DAEMON_PRINCIPAL,
crate::identity::sentinels::SYSTEM_PRINCIPAL,
crate::identity::sentinels::FEDERATION_CATCHUP,
crate::identity::sentinels::SUBSCRIPTION_DISPATCH,
crate::identity::sentinels::AI_HTTP_INTERNAL,
crate::identity::sentinels::AI_MIGRATE,
crate::identity::sentinels::EXPORT_INTERNAL,
crate::identity::sentinels::GOVERNANCE_INTERNAL,
crate::identity::sentinels::EMBEDDING_BACKFILL,
];
pub fn validate_agent_id_shape(agent_id: &str) -> Result<()> {
if agent_id.is_empty() {
bail!("agent_id cannot be empty");
}
if agent_id.len() > MAX_AGENT_ID_LEN {
bail!("agent_id exceeds max length of {MAX_AGENT_ID_LEN} bytes");
}
for c in agent_id.chars() {
if !(c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ':' | '@' | '.' | '/')) {
bail!("agent_id contains invalid character '{c}' (allowed: alphanumeric, _-:@./)");
}
}
if agent_id.contains("..") {
bail!("agent_id may not contain '..' (path-traversal guard)");
}
if agent_id.starts_with('/') {
bail!("agent_id may not start with '/' (path-traversal guard)");
}
Ok(())
}
pub fn validate_agent_id(agent_id: &str) -> Result<()> {
validate_agent_id_shape(agent_id)?;
if RESERVED_AGENT_IDS.contains(&agent_id) {
bail!(
"agent_id '{agent_id}' is reserved for internal use and cannot be supplied by wire \
callers"
);
}
Ok(())
}
pub fn validate_agent_pubkey_b64(pubkey_b64: &str) -> Result<()> {
let trimmed = pubkey_b64.trim();
if trimmed.is_empty() {
bail!("agent_pubkey cannot be empty");
}
if pubkey_b64.len() > MAX_AGENT_PUBKEY_B64_LEN {
bail!("agent_pubkey exceeds max length of {MAX_AGENT_PUBKEY_B64_LEN} bytes");
}
crate::identity::keypair::decode_public_base64(trimmed)
.map_err(|e| anyhow::anyhow!("agent_pubkey is not a valid Ed25519 public key: {e:#}"))?;
Ok(())
}
pub fn validate_scope(scope: &str) -> Result<()> {
if scope.is_empty() {
bail!("scope cannot be empty");
}
if !VALID_SCOPES.contains(&scope) {
bail!(
"invalid scope '{}' — must be one of: {}",
scope,
VALID_SCOPES.join(", ")
);
}
Ok(())
}
pub fn validate_governance_policy(policy: &crate::models::GovernancePolicy) -> Result<()> {
use crate::models::{ApproverType, GovernanceLevel};
match &policy.core.approver {
ApproverType::Human => {}
ApproverType::Agent(id) => {
validate_agent_id(id)?;
}
ApproverType::Consensus(n) => {
if *n == 0 {
bail!("governance.approver.consensus quorum must be >= 1");
}
}
}
let uses_approve = matches!(policy.core.write, GovernanceLevel::Approve)
|| matches!(policy.core.promote, GovernanceLevel::Approve)
|| matches!(policy.core.delete, GovernanceLevel::Approve);
if uses_approve
&& let ApproverType::Consensus(n) = &policy.core.approver
&& *n == 0
{
bail!("governance uses 'approve' level but approver consensus is 0");
}
Ok(())
}
const MAX_AGENT_TYPE_LEN: usize = 64;
pub fn validate_agent_type(agent_type: &str) -> Result<()> {
if agent_type.is_empty() {
bail!("agent_type cannot be empty");
}
if agent_type.len() > MAX_AGENT_TYPE_LEN {
bail!("agent_type exceeds max length of {MAX_AGENT_TYPE_LEN} bytes");
}
if VALID_AGENT_TYPES.contains(&agent_type) {
return Ok(());
}
if let Some(name) = agent_type.strip_prefix("ai:") {
if name.is_empty() {
bail!("agent_type 'ai:' must include a name (e.g. 'ai:claude-opus-4.7')");
}
if name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
{
return Ok(());
}
bail!(
"agent_type '{agent_type}' contains invalid characters in the ai: name \
part (allowed: alphanumeric, _-.)"
);
}
let valid = VALID_AGENT_TYPES.join(", ");
bail!("invalid agent_type '{agent_type}' — must be one of: {valid} (or any ai:<name> form)");
}
pub fn validate_capabilities(caps: &[String]) -> Result<()> {
validate_tags(caps)
}
pub fn validate_tags(tags: &[String]) -> Result<()> {
if tags.len() > MAX_TAGS_COUNT {
bail!("too many tags (max {MAX_TAGS_COUNT})");
}
for tag in tags {
let trimmed = tag.trim();
if trimmed.is_empty() {
bail!("tags cannot contain empty strings");
}
if trimmed.len() > MAX_TAG_LEN {
let preview: String = trimmed.chars().take(20).collect();
bail!("tag '{preview}...' exceeds max length of {MAX_TAG_LEN} bytes");
}
if !is_clean_string(trimmed) {
bail!("tag contains invalid characters");
}
}
Ok(())
}
pub fn validate_id(id: &str) -> Result<()> {
if id.trim().is_empty() {
bail!("id cannot be empty");
}
if id.len() > MAX_ID_LEN {
bail!("id exceeds max length of {MAX_ID_LEN} bytes");
}
if !is_clean_string(id) {
bail!("id contains invalid characters");
}
if id.contains("..") {
bail!("id may not contain '..' (path-traversal guard)");
}
if id.contains('/') || id.contains('\\') {
bail!("id may not contain '/' or '\\' (path-traversal guard)");
}
if !id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b':' | b'.' | b'@' | b'-'))
{
bail!(
"id contains characters outside the allowed set [A-Za-z0-9_:.@-] \
(path-traversal guard)"
);
}
Ok(())
}
pub fn validate_expires_at(expires_at: Option<&str>) -> Result<()> {
if let Some(ts) = expires_at {
if !is_valid_rfc3339(ts) {
bail!("expires_at is not valid RFC3339: '{ts}'");
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts)
&& dt < chrono::Utc::now()
{
bail!("expires_at is in the past");
}
}
Ok(())
}
pub fn validate_ttl_secs(ttl: Option<i64>) -> Result<()> {
if let Some(secs) = ttl {
if secs <= 0 {
bail!("ttl_secs must be positive (got {secs})");
}
if secs > 365 * crate::SECS_PER_DAY {
bail!("ttl_secs exceeds maximum of 1 year");
}
}
Ok(())
}
pub fn validate_metadata(metadata: &serde_json::Value) -> Result<()> {
if !metadata.is_object() {
bail!("metadata must be a JSON object");
}
let serialized = serde_json::to_string(metadata)
.map_err(|e| anyhow::anyhow!("metadata is not valid JSON: {e}"))?;
if serialized.len() > MAX_METADATA_SIZE {
bail!(
"metadata exceeds max size of {MAX_METADATA_SIZE} bytes (got {})",
serialized.len()
);
}
let depth = json_depth(metadata);
if depth > MAX_METADATA_DEPTH {
bail!("metadata nesting depth exceeds limit of {MAX_METADATA_DEPTH} (got {depth})");
}
Ok(())
}
fn json_depth(val: &serde_json::Value) -> usize {
match val {
serde_json::Value::Object(map) => 1 + map.values().map(json_depth).max().unwrap_or(0),
serde_json::Value::Array(arr) => 1 + arr.iter().map(json_depth).max().unwrap_or(0),
_ => 0,
}
}
pub fn validate_relation(relation: &str) -> Result<()> {
if relation.trim().is_empty() {
bail!("relation cannot be empty");
}
if relation.len() > MAX_RELATION_LEN {
bail!("relation exceeds max length of {MAX_RELATION_LEN} bytes");
}
if VALID_RELATIONS.contains(&relation) {
return Ok(());
}
let ok = !relation.is_empty()
&& relation
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
if !ok {
bail!(
"invalid relation '{}' — must match [a-z0-9_]+ or be one of: {}",
relation,
VALID_RELATIONS.join(", ")
);
}
Ok(())
}
pub fn validate_confidence(confidence: f64) -> Result<()> {
if confidence.is_nan() || confidence.is_infinite() {
bail!("confidence must be a finite number");
}
if !(0.0..=1.0).contains(&confidence) {
bail!("confidence must be between 0.0 and 1.0 (got {confidence})");
}
Ok(())
}
pub fn validate_priority(priority: i32) -> Result<()> {
if !(1..=10).contains(&priority) {
bail!("priority must be between 1 and 10 (got {priority})");
}
Ok(())
}
const MAX_CITATIONS_PER_MEMORY: usize = 64;
const MAX_SOURCE_URI_LEN: usize = 4_096;
pub(crate) const VALID_SOURCE_URI_SCHEMES: &[&str] = &["uri:", "doc:", "file:"];
pub fn validate_citation(c: &Citation) -> Result<()> {
validate_source_uri(&c.uri)?;
if !is_valid_rfc3339(&c.accessed_at) {
bail!(
"citation.accessed_at is not valid RFC3339: '{}'",
c.accessed_at
);
}
if let Some(ref h) = c.hash {
if h.len() != 64 || !h.chars().all(|ch| ch.is_ascii_hexdigit()) {
bail!("citation.hash must be 64 hex characters (SHA-256 digest)");
}
}
if let Some(ref span) = c.span {
validate_source_span(span)?;
}
Ok(())
}
pub fn validate_citations(citations: &[Citation]) -> Result<()> {
if citations.len() > MAX_CITATIONS_PER_MEMORY {
bail!(
"too many citations: {} exceeds cap of {MAX_CITATIONS_PER_MEMORY}",
citations.len()
);
}
for c in citations {
validate_citation(c)?;
}
Ok(())
}
pub fn validate_source_uri(s: &str) -> Result<()> {
let trimmed = s.trim();
if trimmed.is_empty() {
bail!("source URI cannot be empty");
}
if trimmed.len() > MAX_SOURCE_URI_LEN {
bail!("source URI exceeds max length of {MAX_SOURCE_URI_LEN} bytes");
}
if !is_clean_string(trimmed) {
bail!("source URI contains invalid control characters");
}
let matched = VALID_SOURCE_URI_SCHEMES
.iter()
.find(|prefix| trimmed.starts_with(*prefix));
match matched {
Some(prefix) => {
let payload = &trimmed[prefix.len()..];
if payload.trim().is_empty() {
bail!("source URI scheme '{prefix}' has empty payload");
}
Ok(())
}
None => bail!(
"source URI must start with one of: {}",
VALID_SOURCE_URI_SCHEMES.join(", ")
),
}
}
pub fn validate_source_span(span: &SourceSpan) -> Result<()> {
if span.start >= span.end {
bail!(
"source_span requires start < end (got start={}, end={})",
span.start,
span.end
);
}
Ok(())
}
pub fn validate_source_span_for_body(span: &SourceSpan, body: &str) -> Result<()> {
validate_source_span(span)?;
if span.end > body.len() {
bail!(
"source_span end={} exceeds body length {}",
span.end,
body.len()
);
}
if !body.is_char_boundary(span.start) {
bail!(
"source_span start={} is not a UTF-8 char boundary in body",
span.start
);
}
if !body.is_char_boundary(span.end) {
bail!(
"source_span end={} is not a UTF-8 char boundary in body",
span.end
);
}
Ok(())
}
pub fn validate_create(mem: &CreateMemory) -> Result<()> {
validate_title(&mem.title)?;
validate_content(&mem.content)?;
validate_namespace(&mem.namespace)?;
validate_source(&mem.source)?;
validate_tags(&mem.tags)?;
validate_priority(mem.priority)?;
if let Some(confidence) = mem.confidence {
validate_confidence(confidence)?;
}
validate_expires_at(mem.expires_at.as_deref())?;
validate_ttl_secs(mem.ttl_secs)?;
validate_metadata(&mem.metadata)?;
validate_kind(mem.kind.as_deref())?;
validate_citations(&mem.citations)?;
if let Some(ref uri) = mem.source_uri {
validate_source_uri(uri)?;
}
if let Some(ref span) = mem.source_span {
validate_source_span(span)?;
}
Ok(())
}
pub fn validate_kind(kind: Option<&str>) -> Result<()> {
if let Some(s) = kind
&& crate::models::MemoryKind::from_str(s).is_none()
{
let expected = crate::models::MemoryKind::all()
.iter()
.map(|k| k.as_str())
.collect::<Vec<_>>()
.join(", ");
bail!("invalid kind '{s}' (expected one of: {expected})");
}
Ok(())
}
pub fn validate_memory(mem: &Memory) -> Result<()> {
validate_id(&mem.id)?;
validate_title(&mem.title)?;
validate_content(&mem.content)?;
validate_namespace(&mem.namespace)?;
validate_source(&mem.source)?;
validate_tags(&mem.tags)?;
validate_priority(mem.priority)?;
validate_confidence(mem.confidence)?;
if mem.access_count < 0 {
bail!("access_count cannot be negative");
}
if !is_valid_rfc3339(&mem.created_at) {
bail!("created_at is not valid RFC3339");
}
if !is_valid_rfc3339(&mem.updated_at) {
bail!("updated_at is not valid RFC3339");
}
if let Some(ref ts) = mem.last_accessed_at
&& !is_valid_rfc3339(ts)
{
bail!("last_accessed_at is not valid RFC3339");
}
if let Some(ref ts) = mem.expires_at
&& !is_valid_rfc3339(ts)
{
bail!("expires_at is not valid RFC3339");
}
validate_metadata(&mem.metadata)?;
validate_citations(&mem.citations)?;
if let Some(ref uri) = mem.source_uri {
validate_source_uri(uri)?;
}
if let Some(ref span) = mem.source_span {
validate_source_span(span)?;
}
Ok(())
}
pub fn validate_update(update: &UpdateMemory) -> Result<()> {
if let Some(ref t) = update.title {
validate_title(t)?;
}
if let Some(ref c) = update.content {
validate_content(c)?;
}
if let Some(ref ns) = update.namespace {
validate_namespace(ns)?;
}
if let Some(ref tags) = update.tags {
validate_tags(tags)?;
}
if let Some(p) = update.priority {
validate_priority(p)?;
}
if let Some(c) = update.confidence {
validate_confidence(c)?;
}
if let Some(ref ts) = update.expires_at {
validate_expires_at_format(ts)?;
}
if let Some(ref meta) = update.metadata {
validate_metadata(meta)?;
}
if let Some(ref uri) = update.source_uri {
validate_source_uri(uri)?;
}
Ok(())
}
pub fn validate_expires_at_format(ts: &str) -> Result<()> {
if !is_valid_rfc3339(ts) {
bail!("expires_at is not valid RFC3339: '{ts}'");
}
Ok(())
}
pub fn validate_link(source_id: &str, target_id: &str, relation: &str) -> Result<()> {
validate_id(source_id)?;
validate_id(target_id)?;
validate_relation(relation)?;
if source_id == target_id {
bail!("cannot link a memory to itself");
}
Ok(())
}
pub fn validate_consolidate(
ids: &[String],
title: &str,
summary: &str,
namespace: &str,
) -> Result<()> {
if ids.len() < 2 {
bail!("need at least 2 memory IDs to consolidate");
}
if ids.len() > 100 {
bail!("cannot consolidate more than 100 memories at once");
}
let mut seen = std::collections::HashSet::new();
for id in ids {
validate_id(id)?;
if !seen.insert(id) {
bail!("duplicate memory ID: {id}");
}
}
validate_title(title)?;
validate_content(summary)?;
validate_namespace(namespace)?;
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationError {
pub field: String,
pub reason: String,
}
impl ValidationError {
#[must_use]
pub fn new(field: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
field: field.into(),
reason: reason.into(),
}
}
fn from_anyhow(field: &str, err: anyhow::Error) -> Self {
Self::new(field, err.to_string())
}
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.reason)
}
}
impl std::error::Error for ValidationError {}
pub struct RequestValidator;
impl RequestValidator {
pub fn validate_create(req: &CreateMemory) -> Result<(), ValidationError> {
validate_create(req).map_err(|e| ValidationError::from_anyhow("create", e))
}
pub fn validate_update(req: &UpdateMemory) -> Result<(), ValidationError> {
validate_update(req).map_err(|e| ValidationError::from_anyhow("update", e))
}
pub fn validate_memory(req: &Memory) -> Result<(), ValidationError> {
validate_memory(req).map_err(|e| ValidationError::from_anyhow("memory", e))
}
pub fn validate_link_triple(
source_id: &str,
target_id: &str,
relation: &str,
) -> Result<(), ValidationError> {
validate_link(source_id, target_id, relation)
.map_err(|e| ValidationError::from_anyhow("link", e))
}
pub fn validate_consolidate(
ids: &[String],
title: &str,
summary: &str,
namespace: &str,
) -> Result<(), ValidationError> {
validate_consolidate(ids, title, summary, namespace)
.map_err(|e| ValidationError::from_anyhow("consolidate", e))
}
pub fn validate_id(id: &str) -> Result<(), ValidationError> {
validate_id(id).map_err(|e| ValidationError::from_anyhow("id", e))
}
pub fn validate_namespace(ns: &str) -> Result<(), ValidationError> {
validate_namespace(ns).map_err(|e| ValidationError::from_anyhow("namespace", e))
}
pub fn validate_agent_id(agent_id: &str) -> Result<(), ValidationError> {
validate_agent_id(agent_id).map_err(|e| ValidationError::from_anyhow("agent_id", e))
}
pub fn validate_id_and_namespace(id: &str, ns: &str) -> Result<(), ValidationError> {
Self::validate_id(id)?;
Self::validate_namespace(ns)?;
Ok(())
}
pub fn validate_owner_write(id: &str, ns: &str, agent_id: &str) -> Result<(), ValidationError> {
Self::validate_id(id)?;
Self::validate_namespace(ns)?;
Self::validate_agent_id(agent_id)?;
Ok(())
}
pub fn validate_confidence_and_priority(
confidence: f64,
priority: i32,
) -> Result<(), ValidationError> {
validate_confidence(confidence)
.map_err(|e| ValidationError::from_anyhow("confidence", e))?;
validate_priority(priority).map_err(|e| ValidationError::from_anyhow("priority", e))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_title() {
assert!(validate_title("BIND9 custom build").is_ok());
assert!(validate_title("").is_err());
assert!(validate_title(" ").is_err());
assert!(validate_title(&"x".repeat(513)).is_err());
assert!(validate_title("has\0null").is_err());
}
#[test]
fn test_valid_namespace_flat_backwards_compat() {
assert!(validate_namespace("my-project").is_ok());
assert!(validate_namespace("global").is_ok());
assert!(validate_namespace("under_score").is_ok());
assert!(validate_namespace("ai-memory-mcp-dev").is_ok());
assert!(validate_namespace("_agents").is_ok());
}
#[test]
fn test_valid_namespace_rejections_preserved() {
assert!(validate_namespace("").is_err());
assert!(validate_namespace(" ").is_err());
assert!(validate_namespace("has space").is_err());
assert!(validate_namespace("has\\backslash").is_err());
assert!(validate_namespace("has\0null").is_err());
assert!(validate_namespace("has\x07bell").is_err());
}
#[test]
fn test_namespace_rejects_dot_segments_redteam_240() {
assert!(validate_namespace("acme/../other").is_err());
assert!(validate_namespace("acme/./other").is_err());
assert!(validate_namespace("..").is_err());
assert!(validate_namespace(".").is_err());
assert!(validate_namespace("acme/team/..").is_err());
assert!(validate_namespace("../acme").is_err());
assert!(validate_namespace("acme/team..special").is_ok());
assert!(validate_namespace("acme/.dotfile").is_ok());
}
#[test]
fn test_namespace_length_bumped_to_512() {
assert!(validate_namespace(&"x".repeat(128)).is_ok());
assert!(validate_namespace(&"x".repeat(512)).is_ok());
assert!(validate_namespace(&"x".repeat(513)).is_err());
}
#[test]
fn test_hierarchical_paths_accepted() {
assert!(validate_namespace("alphaone/engineering").is_ok());
assert!(validate_namespace("alphaone/engineering/platform").is_ok());
assert!(validate_namespace("a/b/c/d/e/f/g/h").is_ok(), "8 levels OK");
}
#[test]
fn test_hierarchical_depth_cap() {
assert!(validate_namespace("a/b/c/d/e/f/g/h/i").is_err());
}
#[test]
fn test_hierarchical_rejects_leading_slash() {
assert!(validate_namespace("/alphaone/engineering").is_err());
}
#[test]
fn test_hierarchical_rejects_trailing_slash() {
assert!(validate_namespace("alphaone/engineering/").is_err());
}
#[test]
fn test_hierarchical_rejects_empty_segments() {
assert!(validate_namespace("alphaone//engineering").is_err());
assert!(validate_namespace("a///b").is_err());
}
#[test]
fn test_hierarchical_rejects_control_chars() {
assert!(validate_namespace("a/b\x07c").is_err());
assert!(validate_namespace("a/b\0c").is_err());
}
#[test]
fn test_normalize_namespace_strips_slashes() {
assert_eq!(
normalize_namespace("/alphaone/engineering/"),
"alphaone/engineering"
);
assert_eq!(normalize_namespace("///a///b///"), "a/b");
}
#[test]
fn test_normalize_namespace_lowercases() {
assert_eq!(
normalize_namespace("AlphaOne/Engineering"),
"alphaone/engineering"
);
assert_eq!(normalize_namespace("MYAPP"), "myapp");
}
#[test]
fn test_normalize_namespace_trims_whitespace() {
assert_eq!(normalize_namespace(" alphaone/eng "), "alphaone/eng");
}
#[test]
fn test_normalize_then_validate_roundtrip() {
let raw = "/AlphaOne//Engineering/Platform/";
let norm = normalize_namespace(raw);
assert_eq!(norm, "alphaone/engineering/platform");
assert!(validate_namespace(&norm).is_ok());
}
#[test]
fn test_valid_source() {
assert!(validate_source("user").is_ok());
assert!(validate_source("claude").is_ok());
assert!(validate_source("hook").is_ok());
assert!(validate_source("api").is_ok());
assert!(validate_source("cli").is_ok());
assert!(validate_source("import").is_ok());
assert!(validate_source("").is_err());
assert!(validate_source("random").is_err());
}
#[test]
fn test_valid_agent_id() {
assert!(validate_agent_id("alice").is_ok());
assert!(validate_agent_id("ai:claude-code@host-1:pid-123").is_ok());
assert!(validate_agent_id("host:dev-1:pid-9-deadbeef").is_ok());
assert!(validate_agent_id("anonymous:req-abcdef01").is_ok());
assert!(validate_agent_id("anonymous:pid-42-0123abcd").is_ok());
assert!(validate_agent_id("spiffe://example.org/ns/prod").is_ok());
assert!(validate_agent_id("a").is_ok());
assert!(validate_agent_id(&"a".repeat(128)).is_ok());
}
#[test]
fn test_invalid_agent_id() {
assert!(validate_agent_id("").is_err());
assert!(validate_agent_id(&"a".repeat(129)).is_err());
assert!(validate_agent_id("alice bob").is_err());
assert!(validate_agent_id("alice\tbob").is_err());
assert!(validate_agent_id(" alice").is_err());
assert!(validate_agent_id("alice ").is_err());
assert!(validate_agent_id("has\0null").is_err());
assert!(validate_agent_id("has\x07bell").is_err());
assert!(validate_agent_id("has\nnewline").is_err());
assert!(validate_agent_id("alice;rm").is_err());
assert!(validate_agent_id("alice|cat").is_err());
assert!(validate_agent_id("alice&bg").is_err());
assert!(validate_agent_id("alice$VAR").is_err());
assert!(validate_agent_id("alice`cmd`").is_err());
assert!(validate_agent_id("alice\\bs").is_err());
assert!(validate_agent_id("alice?q").is_err());
assert!(validate_agent_id("alice*glob").is_err());
}
#[test]
fn test_reserved_internal_agent_ids_rejected_977() {
for &reserved in RESERVED_AGENT_IDS {
let r = validate_agent_id(reserved);
assert!(
r.is_err(),
"reserved agent_id '{reserved}' MUST be rejected on the wire (issue #977)",
);
let msg = r.unwrap_err().to_string();
assert!(
msg.contains("reserved for internal use"),
"reserved-name reject must surface the dedicated reason; got: {msg}",
);
}
}
#[test]
fn test_legitimate_agent_ids_still_pass_after_977() {
for legitimate in [
"alice",
"ai:claude-code@host-1:pid-123",
"host:dev-1:pid-9-deadbeef",
"anonymous:req-abcdef01",
"anonymous:pid-42-0123abcd",
"spiffe://example.org/ns/prod",
"daemon-1",
"system-admin",
"ai:daemon-impostor",
"federation-catchup-v2",
"subscription-dispatch-replica",
"ai:http-internal-shadow",
"export-internal-tester",
"governance-internal-audit",
] {
assert!(
validate_agent_id(legitimate).is_ok(),
"legitimate NHI shape '{legitimate}' MUST still pass after #977",
);
}
}
#[test]
fn test_agent_id_rejects_path_traversal_1251() {
for traversal in [
"..",
"../foo",
"foo/..",
"foo/../bar",
"ai:claude/../etc",
"host:..",
"....", ] {
let r = validate_agent_id_shape(traversal);
assert!(
r.is_err(),
"path-traversal shape '{traversal}' must be rejected by validate_agent_id_shape",
);
let msg = r.unwrap_err().to_string();
assert!(
msg.contains("path-traversal") || msg.contains(".."),
"reject message for '{traversal}' should cite path-traversal; got: {msg}",
);
}
let r = validate_agent_id_shape("/etc/keys");
assert!(r.is_err(), "leading '/' agent_id must be rejected");
assert!(
r.unwrap_err().to_string().contains("path-traversal"),
"leading '/' must cite path-traversal in the error",
);
}
#[test]
fn test_agent_id_spiffe_still_ok_after_1251() {
assert!(validate_agent_id_shape("spiffe://example.org/ns/prod").is_ok());
assert!(validate_agent_id_shape("spiffe://a/b").is_ok());
}
#[test]
fn test_agent_pubkey_b64_accepts_generated_key() {
let kp = crate::identity::keypair::generate("ai:curator").expect("generate");
let b64 = kp.public_base64();
assert!(
validate_agent_pubkey_b64(&b64).is_ok(),
"exported pubkey base64 must validate; got: {b64}",
);
let padded = format!(" {b64}\n");
assert!(validate_agent_pubkey_b64(&padded).is_ok());
}
#[test]
fn test_agent_pubkey_b64_accepts_standard_padded() {
use base64::Engine as _;
let kp = crate::identity::keypair::generate("ai:curator").expect("generate");
let padded = base64::engine::general_purpose::STANDARD.encode(kp.public.to_bytes());
assert!(
validate_agent_pubkey_b64(&padded).is_ok(),
"standard-padded pubkey base64 must validate; got: {padded}",
);
}
#[test]
fn test_agent_pubkey_b64_rejects_empty() {
assert!(validate_agent_pubkey_b64("").is_err());
assert!(validate_agent_pubkey_b64(" \n").is_err());
}
#[test]
fn test_agent_pubkey_b64_rejects_overlong() {
let overlong = "A".repeat(MAX_AGENT_PUBKEY_B64_LEN + 1);
let err = validate_agent_pubkey_b64(&overlong).unwrap_err();
assert!(
err.to_string().contains("max length"),
"overlong pubkey must cite the length bound; got: {err}",
);
}
#[test]
fn test_agent_pubkey_b64_rejects_malformed() {
assert!(validate_agent_pubkey_b64("!!!not-base64!!!").is_err());
use base64::Engine as _;
let short = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 16]);
let err = validate_agent_pubkey_b64(&short).unwrap_err();
assert!(
err.to_string().contains("not a valid Ed25519 public key"),
"wrong-length key must surface the dedicated reason; got: {err}",
);
}
#[test]
fn test_validate_governance_policy_default_ok() {
let p = crate::models::GovernancePolicy::default();
assert!(validate_governance_policy(&p).is_ok());
}
#[test]
fn test_validate_id_rejects_path_traversal_1051() {
for bad in [
"../etc/passwd",
"..",
"../../",
"../../../tmp/evil",
"foo/../bar",
"foo/bar",
"/foo",
"foo/",
"foo//bar",
"foo\\bar",
"C:\\Users\\foo",
"foo bar", "rm -rf", "foo;rm", "..\\..\\evil", ] {
assert!(
validate_id(bad).is_err(),
"validate_id('{bad}') must reject (path-traversal guard #1051)"
);
}
}
#[test]
fn test_validate_id_accepts_legitimate_ids_1051() {
for ok in [
"550e8400-e29b-41d4-a716-446655440000", "mem.abc123",
"agent:claude-opus-4.7",
"user@example.com",
"namespace-foo_bar",
"Mem_2026.05.21_xyz",
] {
assert!(
validate_id(ok).is_ok(),
"validate_id('{ok}') must accept (legitimate id shape #1051)"
);
}
}
#[test]
fn test_validate_governance_consensus_zero_rejected() {
use crate::models::{ApproverType, CorePolicy, GovernanceLevel, GovernancePolicy};
let p = GovernancePolicy {
core: CorePolicy {
write: GovernanceLevel::Any,
promote: GovernanceLevel::Any,
delete: GovernanceLevel::Owner,
approver: ApproverType::Consensus(0),
inherit: true,
max_reflection_depth: None,
},
..Default::default()
};
assert!(validate_governance_policy(&p).is_err());
}
#[test]
fn test_validate_governance_agent_id_checked() {
use crate::models::{ApproverType, CorePolicy, GovernanceLevel, GovernancePolicy};
let bad = GovernancePolicy {
core: CorePolicy {
write: GovernanceLevel::Any,
promote: GovernanceLevel::Any,
delete: GovernanceLevel::Owner,
approver: ApproverType::Agent("has space".to_string()),
inherit: true,
max_reflection_depth: None,
},
..Default::default()
};
assert!(validate_governance_policy(&bad).is_err());
let good = GovernancePolicy {
core: CorePolicy {
write: GovernanceLevel::Any,
promote: GovernanceLevel::Any,
delete: GovernanceLevel::Owner,
approver: ApproverType::Agent("alice".to_string()),
inherit: true,
max_reflection_depth: None,
},
..Default::default()
};
assert!(validate_governance_policy(&good).is_ok());
}
#[test]
fn test_valid_scope() {
for s in ["private", "team", "unit", "org", "collective"] {
assert!(validate_scope(s).is_ok(), "{s} must be valid");
}
}
#[test]
fn test_invalid_scope() {
assert!(validate_scope("").is_err());
assert!(validate_scope("public").is_err());
assert!(validate_scope("PRIVATE").is_err());
assert!(validate_scope("personal").is_err());
}
#[test]
fn test_valid_agent_type_curated_values() {
assert!(validate_agent_type("ai:claude-opus-4.6").is_ok());
assert!(validate_agent_type("ai:codex-5.4").is_ok());
assert!(validate_agent_type("ai:grok-4.2").is_ok());
assert!(validate_agent_type("human").is_ok());
assert!(validate_agent_type("system").is_ok());
}
#[test]
fn test_valid_agent_type_open_ai_namespace_redteam_235() {
assert!(validate_agent_type("ai:claude-opus-4.8").is_ok());
assert!(validate_agent_type("ai:gpt-5").is_ok());
assert!(validate_agent_type("ai:gemini-2.5").is_ok());
assert!(validate_agent_type("ai:custom_internal-model.v2").is_ok());
assert!(validate_agent_type("ai:claude").is_ok());
}
#[test]
fn test_invalid_agent_type() {
assert!(validate_agent_type("").is_err());
assert!(validate_agent_type("AI:CLAUDE").is_err());
assert!(validate_agent_type("bogus").is_err());
assert!(validate_agent_type("ai:").is_err());
assert!(validate_agent_type("ai:foo bar").is_err());
assert!(validate_agent_type("ai:foo;rm").is_err());
assert!(validate_agent_type(&format!("ai:{}", "x".repeat(80))).is_err());
}
#[test]
fn test_agents_namespace_accepted() {
assert!(validate_namespace("_agents").is_ok());
}
#[test]
fn test_valid_tags() {
assert!(validate_tags(&["dns".to_string(), "bind9".to_string()]).is_ok());
assert!(validate_tags(&[]).is_ok());
assert!(validate_tags(&[String::new()]).is_err());
let too_many: Vec<String> = (0..51).map(|i| format!("tag{i}")).collect();
assert!(validate_tags(&too_many).is_err());
}
#[test]
fn test_valid_relation() {
assert!(validate_relation("related_to").is_ok());
assert!(validate_relation("derived_from").is_ok());
assert!(validate_relation("contradicts").is_ok());
assert!(validate_relation("supersedes").is_ok());
assert!(validate_relation("reflects_on").is_ok());
assert!(validate_relation("s82_chain_marker").is_ok());
assert!(validate_relation("invented_relation").is_ok());
assert!(validate_relation("mentions").is_ok());
assert!(validate_relation("").is_err());
assert!(validate_relation("BAD").is_err());
assert!(validate_relation("bad relation").is_err());
assert!(validate_relation("bad/relation").is_err());
assert!(validate_relation("bad-relation").is_err());
}
#[test]
fn test_valid_confidence() {
assert!(validate_confidence(0.0).is_ok());
assert!(validate_confidence(0.5).is_ok());
assert!(validate_confidence(1.0).is_ok());
assert!(validate_confidence(-0.1).is_err());
assert!(validate_confidence(1.1).is_err());
assert!(validate_confidence(f64::NAN).is_err());
assert!(validate_confidence(f64::INFINITY).is_err());
}
#[test]
fn test_valid_ttl() {
assert!(validate_ttl_secs(None).is_ok());
assert!(validate_ttl_secs(Some(crate::SECS_PER_HOUR)).is_ok());
assert!(validate_ttl_secs(Some(0)).is_err());
assert!(validate_ttl_secs(Some(-1)).is_err());
assert!(validate_ttl_secs(Some(366 * crate::SECS_PER_DAY)).is_err());
}
#[test]
fn test_self_link_rejected() {
assert!(validate_link("abc", "abc", "related_to").is_err());
assert!(validate_link("abc", "def", "related_to").is_ok());
}
#[test]
fn test_valid_metadata() {
assert!(validate_metadata(&serde_json::json!({})).is_ok());
assert!(validate_metadata(&serde_json::json!({"key": "value"})).is_ok());
assert!(validate_metadata(&serde_json::json!({"nested": {"a": 1}})).is_ok());
assert!(validate_metadata(&serde_json::json!("string")).is_err());
assert!(validate_metadata(&serde_json::json!(42)).is_err());
assert!(validate_metadata(&serde_json::json!([1, 2])).is_err());
assert!(validate_metadata(&serde_json::json!(null)).is_err());
}
#[test]
fn test_clean_string_rejects_control_chars() {
assert!(is_clean_string("normal text"));
assert!(is_clean_string("with\nnewline"));
assert!(is_clean_string("with\ttab"));
assert!(!is_clean_string("has\0null"));
assert!(!is_clean_string("has\x07bell"));
assert!(!is_clean_string("has\x1b[31mANSI\x1b[0m"));
assert!(!is_clean_string("has\x08backspace"));
}
#[test]
fn test_oversized_metadata_rejected() {
let big_value = "x".repeat(MAX_METADATA_SIZE);
let meta = serde_json::json!({"big": big_value});
assert!(validate_metadata(&meta).is_err());
}
#[test]
fn test_deeply_nested_metadata_rejected() {
let mut val = serde_json::json!("leaf");
for _ in 0..33 {
val = serde_json::json!({"nested": val});
}
assert!(validate_metadata(&val).is_err());
let mut val = serde_json::json!("leaf");
for _ in 0..31 {
val = serde_json::json!({"nested": val});
}
assert!(validate_metadata(&val).is_ok());
}
use proptest::prelude::*;
proptest! {
#[test]
fn prop_validate_title_rejects_empty_strings_only_when_actually_empty(
ws in r"[ \t\n]{0,16}",
tail in r"[A-Za-z0-9 _\-.,!?]{0,80}",
) {
let title = format!("{ws}{tail}{ws}");
let trimmed_empty = title.trim().is_empty();
let result = validate_title(&title);
if trimmed_empty {
prop_assert!(result.is_err(), "whitespace-only title must reject: {:?}", title);
} else if title.chars().count() <= 512 {
prop_assert!(result.is_ok(), "non-empty trimmed title must accept: {:?}", title);
}
}
}
proptest! {
#[test]
fn prop_validate_namespace_rejects_invalid_chars(
base in r"[a-z][a-z0-9_-]{0,20}",
bad in prop::sample::select(&[' ', '\\', '\0', '\x07', '\x1b', '\x08']),
) {
let ns = format!("{base}{bad}suffix");
prop_assert!(
validate_namespace(&ns).is_err(),
"namespace with bad char {:?} must reject: {:?}", bad, ns
);
}
}
proptest! {
#[test]
fn prop_validate_namespace_accepts_valid_hierarchy(
segs in prop::collection::vec(r"[a-z][a-z0-9_-]{0,20}", 1..=8),
) {
let safe: Vec<String> = segs
.into_iter()
.filter(|s| s != "." && s != "..")
.collect();
if safe.is_empty() {
return Ok(());
}
let ns = safe.join("/");
prop_assert!(
validate_namespace(&ns).is_ok(),
"valid hierarchy must accept: {:?}", ns
);
}
}
proptest! {
#[test]
fn prop_validate_priority_rejects_outside_range(p in -1000i32..1000i32) {
let result = validate_priority(p);
if (1..=10).contains(&p) {
prop_assert!(result.is_ok(), "priority {p} (in 1..=10) must accept");
} else {
prop_assert!(result.is_err(), "priority {p} (outside 1..=10) must reject");
}
}
}
proptest! {
#[test]
fn prop_validate_confidence_clamps_or_rejects(c in -10.0f64..10.0f64) {
let result = validate_confidence(c);
if (0.0..=1.0).contains(&c) {
prop_assert!(result.is_ok(), "confidence {c} in [0,1] must accept");
} else {
prop_assert!(result.is_err(), "confidence {c} outside [0,1] must reject");
}
}
#[test]
fn prop_validate_confidence_nan_inf_always_rejected(_u in Just(())) {
prop_assert!(validate_confidence(f64::NAN).is_err());
prop_assert!(validate_confidence(f64::INFINITY).is_err());
prop_assert!(validate_confidence(f64::NEG_INFINITY).is_err());
}
}
proptest! {
#[test]
fn prop_validate_link_rejects_self_link_for_every_relation(
id in r"[a-z][a-zA-Z0-9_-]{0,32}",
rel_idx in 0usize..5,
) {
let relations = [
"related_to",
"supersedes",
"contradicts",
"derived_from",
"reflects_on",
];
let rel = relations[rel_idx];
let result = validate_link(&id, &id, rel);
prop_assert!(result.is_err(), "self-link must reject for relation {rel}, id {:?}", id);
}
}
#[test]
fn test_title_accepts_zero_width_joiner() {
assert!(validate_title("emoji\u{200D}joiner").is_ok());
}
#[test]
fn test_title_accepts_rtl_marks() {
assert!(validate_title("hello\u{200F}world").is_ok());
assert!(validate_title("hello\u{200E}world").is_ok());
}
#[test]
fn test_title_accepts_combining_chars() {
assert!(validate_title("cafe\u{0301}").is_ok());
}
#[test]
fn test_title_rejects_unicode_bom_as_control() {
assert!(validate_title("foo\u{FEFF}bar").is_ok());
}
#[test]
fn content_with_control_chars_rejected() {
let err = validate_content("has\x07bell").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("invalid characters"), "got: {msg}");
}
#[test]
fn content_with_null_byte_rejected() {
let err = validate_content("has\0null").unwrap_err();
assert!(format!("{err}").contains("invalid characters"));
}
#[test]
fn source_oversized_rejected() {
let big = "x".repeat(65);
let err = validate_source(&big).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("max length"), "got: {msg}");
}
#[test]
fn governance_approve_with_consensus_zero_rejected() {
use crate::models::{ApproverType, CorePolicy, GovernanceLevel, GovernancePolicy};
let p = GovernancePolicy {
core: CorePolicy {
write: GovernanceLevel::Approve,
promote: GovernanceLevel::Any,
delete: GovernanceLevel::Owner,
approver: ApproverType::Consensus(0),
inherit: true,
max_reflection_depth: None,
},
..Default::default()
};
assert!(validate_governance_policy(&p).is_err());
}
#[test]
fn tag_oversized_rejected_with_preview() {
let big = "x".repeat(129);
let tags = vec![big];
let err = validate_tags(&tags).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("max length"), "got: {msg}");
assert!(msg.contains("xxxxxxxxxxxxxxxxxxxx"), "got: {msg}");
}
#[test]
fn tag_with_control_chars_rejected() {
let tags = vec!["has\x07bell".to_string()];
let err = validate_tags(&tags).unwrap_err();
assert!(format!("{err}").contains("invalid characters"));
}
#[test]
fn expires_at_malformed_rfc3339_rejected() {
let err = validate_expires_at(Some("not-a-date")).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("RFC3339"), "got: {msg}");
assert!(msg.contains("not-a-date"), "got: {msg}");
}
#[test]
fn expires_at_none_is_ok() {
assert!(validate_expires_at(None).is_ok());
}
#[test]
fn expires_at_future_is_ok() {
let future = "2099-01-01T00:00:00Z";
assert!(validate_expires_at(Some(future)).is_ok());
}
#[test]
fn expires_at_past_rejected() {
let past = "2000-01-01T00:00:00Z";
let err = validate_expires_at(Some(past)).unwrap_err();
assert!(format!("{err}").contains("past"));
}
#[test]
fn relation_oversized_rejected() {
let big = "x".repeat(65);
let err = validate_relation(&big).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("max length"), "got: {msg}");
}
fn cm_valid() -> crate::models::CreateMemory {
serde_json::from_value(serde_json::json!({
"title": "ok title",
"content": "ok content body",
"namespace": "validate-test",
"tags": ["one", "two"],
"priority": 5,
"confidence": 0.9,
"source": "api",
"metadata": {"k": "v"},
}))
.expect("fixture deserialises")
}
#[test]
fn validate_create_happy_path() {
let m = cm_valid();
assert!(validate_create(&m).is_ok());
}
#[test]
fn validate_create_propagates_title_error() {
let mut m = cm_valid();
m.title = String::new();
assert!(validate_create(&m).is_err());
}
#[test]
fn validate_create_propagates_content_error() {
let mut m = cm_valid();
m.content = String::new();
assert!(validate_create(&m).is_err());
}
#[test]
fn validate_create_propagates_namespace_error() {
let mut m = cm_valid();
m.namespace = "has space".to_string();
assert!(validate_create(&m).is_err());
}
#[test]
fn validate_create_propagates_source_error() {
let mut m = cm_valid();
m.source = "bogus".to_string();
assert!(validate_create(&m).is_err());
}
#[test]
fn validate_kind_none_is_ok() {
assert!(validate_kind(None).is_ok());
}
#[test]
fn validate_kind_accepts_all_canonical_variants() {
for k in crate::models::MemoryKind::all() {
assert!(
validate_kind(Some(k.as_str())).is_ok(),
"canonical variant {:?} must validate",
k.as_str()
);
}
}
#[test]
fn validate_kind_rejects_wrong_case_unknown_and_whitespace() {
for bad in ["Claim", "bogus", "claim ", "OBSERVATION", ""] {
let err = validate_kind(Some(bad)).unwrap_err().to_string();
assert!(
err.contains("invalid kind") && err.contains("expected one of"),
"expected strict rejection for {bad:?}, got: {err}"
);
}
}
#[test]
fn validate_create_rejects_invalid_kind() {
let mut m = cm_valid();
m.kind = Some("bogus".to_string());
assert!(validate_create(&m).is_err());
}
#[test]
fn validate_create_accepts_valid_kind() {
let mut m = cm_valid();
m.kind = Some("claim".to_string());
assert!(validate_create(&m).is_ok());
}
#[test]
fn validate_create_propagates_tags_error() {
let mut m = cm_valid();
m.tags = vec![String::new()];
assert!(validate_create(&m).is_err());
}
#[test]
fn validate_create_propagates_priority_error() {
let mut m = cm_valid();
m.priority = 11;
assert!(validate_create(&m).is_err());
}
#[test]
fn validate_create_propagates_confidence_error() {
let mut m = cm_valid();
m.confidence = Some(1.5);
assert!(validate_create(&m).is_err());
m.confidence = None;
assert!(validate_create(&m).is_ok());
}
#[test]
fn validate_create_propagates_expires_at_error() {
let mut m = cm_valid();
m.expires_at = Some("not-a-date".to_string());
assert!(validate_create(&m).is_err());
}
#[test]
fn validate_create_propagates_ttl_error() {
let mut m = cm_valid();
m.ttl_secs = Some(-1);
assert!(validate_create(&m).is_err());
}
#[test]
fn validate_create_propagates_metadata_error() {
let mut m = cm_valid();
m.metadata = serde_json::json!("not-an-object");
assert!(validate_create(&m).is_err());
}
fn mem_valid() -> crate::models::Memory {
crate::models::Memory {
id: "mem-1".to_string(),
title: "ok title".to_string(),
content: "ok content".to_string(),
namespace: "validate-test".to_string(),
source: "api".to_string(),
tags: vec!["one".to_string()],
priority: 5,
confidence: 1.0,
access_count: 0,
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
..Default::default()
}
}
#[test]
fn validate_memory_happy_path() {
let m = mem_valid();
assert!(validate_memory(&m).is_ok());
}
#[test]
fn validate_memory_rejects_empty_id() {
let mut m = mem_valid();
m.id = String::new();
assert!(validate_memory(&m).is_err());
}
#[test]
fn validate_memory_rejects_negative_access_count() {
let mut m = mem_valid();
m.access_count = -1;
let err = validate_memory(&m).unwrap_err();
assert!(format!("{err}").contains("access_count"));
}
#[test]
fn validate_memory_rejects_malformed_created_at() {
let mut m = mem_valid();
m.created_at = "not-a-date".to_string();
let err = validate_memory(&m).unwrap_err();
assert!(format!("{err}").contains("created_at"));
}
#[test]
fn validate_memory_rejects_malformed_updated_at() {
let mut m = mem_valid();
m.updated_at = "not-a-date".to_string();
let err = validate_memory(&m).unwrap_err();
assert!(format!("{err}").contains("updated_at"));
}
#[test]
fn validate_memory_rejects_malformed_last_accessed_at() {
let mut m = mem_valid();
m.last_accessed_at = Some("not-a-date".to_string());
let err = validate_memory(&m).unwrap_err();
assert!(format!("{err}").contains("last_accessed_at"));
}
#[test]
fn validate_memory_accepts_valid_last_accessed_at() {
let mut m = mem_valid();
m.last_accessed_at = Some("2026-01-01T00:00:00Z".to_string());
assert!(validate_memory(&m).is_ok());
}
#[test]
fn validate_memory_rejects_malformed_expires_at() {
let mut m = mem_valid();
m.expires_at = Some("not-a-date".to_string());
let err = validate_memory(&m).unwrap_err();
assert!(format!("{err}").contains("expires_at"));
}
#[test]
fn validate_memory_accepts_past_expires_at_for_import() {
let mut m = mem_valid();
m.expires_at = Some("2000-01-01T00:00:00Z".to_string());
assert!(validate_memory(&m).is_ok());
}
fn upd() -> crate::models::UpdateMemory {
serde_json::from_value(serde_json::json!({})).expect("empty UpdateMemory deserialises")
}
#[test]
fn validate_update_empty_is_ok() {
assert!(validate_update(&upd()).is_ok());
}
#[test]
fn validate_update_propagates_title_error() {
let mut u = upd();
u.title = Some(String::new());
assert!(validate_update(&u).is_err());
}
#[test]
fn validate_update_propagates_content_error() {
let mut u = upd();
u.content = Some(String::new());
assert!(validate_update(&u).is_err());
}
#[test]
fn validate_update_propagates_namespace_error() {
let mut u = upd();
u.namespace = Some("has space".to_string());
assert!(validate_update(&u).is_err());
}
#[test]
fn validate_update_propagates_tags_error() {
let mut u = upd();
u.tags = Some(vec![String::new()]);
assert!(validate_update(&u).is_err());
}
#[test]
fn validate_update_propagates_priority_error() {
let mut u = upd();
u.priority = Some(11);
assert!(validate_update(&u).is_err());
}
#[test]
fn validate_update_propagates_confidence_error() {
let mut u = upd();
u.confidence = Some(2.0);
assert!(validate_update(&u).is_err());
}
#[test]
fn validate_update_propagates_expires_at_format_error() {
let mut u = upd();
u.expires_at = Some("not-a-date".to_string());
assert!(validate_update(&u).is_err());
}
#[test]
fn validate_update_allows_past_expires_at() {
let mut u = upd();
u.expires_at = Some("2000-01-01T00:00:00Z".to_string());
assert!(validate_update(&u).is_ok());
}
#[test]
fn validate_update_propagates_metadata_error() {
let mut u = upd();
u.metadata = Some(serde_json::json!("not-an-object"));
assert!(validate_update(&u).is_err());
}
#[test]
fn validate_expires_at_format_accepts_past_date() {
assert!(validate_expires_at_format("2000-01-01T00:00:00Z").is_ok());
assert!(validate_expires_at_format("not-a-date").is_err());
}
#[test]
fn consolidate_too_few_ids_rejected() {
let err = validate_consolidate(&["only-one".to_string()], "title", "summary content", "ns")
.unwrap_err();
assert!(format!("{err}").contains("at least 2"));
}
#[test]
fn consolidate_too_many_ids_rejected() {
let ids: Vec<String> = (0..101).map(|i| format!("id-{i}")).collect();
let err = validate_consolidate(&ids, "title", "summary content", "ns").unwrap_err();
assert!(format!("{err}").contains("100"));
}
#[test]
fn consolidate_duplicate_ids_rejected() {
let ids = vec!["a".to_string(), "a".to_string()];
let err = validate_consolidate(&ids, "title", "summary content", "ns").unwrap_err();
assert!(format!("{err}").contains("duplicate"));
}
#[test]
fn consolidate_invalid_id_rejected() {
let ids = vec!["valid".to_string(), String::new()];
let err = validate_consolidate(&ids, "title", "summary content", "ns").unwrap_err();
assert!(format!("{err}").contains("id"));
}
#[test]
fn consolidate_invalid_title_rejected() {
let ids = vec!["a".to_string(), "b".to_string()];
assert!(validate_consolidate(&ids, "", "summary content", "ns").is_err());
}
#[test]
fn consolidate_invalid_summary_rejected() {
let ids = vec!["a".to_string(), "b".to_string()];
assert!(validate_consolidate(&ids, "title", "", "ns").is_err());
}
#[test]
fn consolidate_invalid_namespace_rejected() {
let ids = vec!["a".to_string(), "b".to_string()];
assert!(validate_consolidate(&ids, "title", "summary content", "has space").is_err());
}
#[test]
fn consolidate_happy_path() {
let ids = vec!["a".to_string(), "b".to_string(), "c".to_string()];
assert!(validate_consolidate(&ids, "title", "summary content", "ns").is_ok());
}
#[test]
fn capabilities_delegates_to_tags() {
assert!(validate_capabilities(&["read".to_string(), "write".to_string()]).is_ok());
assert!(validate_capabilities(&[String::new()]).is_err());
}
#[test]
fn id_oversized_rejected() {
let big = "a".repeat(129);
let err = validate_id(&big).unwrap_err();
assert!(format!("{err}").contains("max length"));
}
#[test]
fn id_with_control_chars_rejected() {
let err = validate_id("has\0null").unwrap_err();
assert!(format!("{err}").contains("invalid characters"));
}
fn good_citation() -> crate::models::Citation {
crate::models::Citation {
uri: "doc:abc".to_string(),
accessed_at: "2026-01-01T00:00:00Z".to_string(),
hash: None,
span: None,
}
}
#[test]
fn validate_source_uri_rejects_empty_string() {
let err = validate_source_uri("").unwrap_err();
assert!(format!("{err}").contains("cannot be empty"));
}
#[test]
fn validate_source_uri_rejects_whitespace_only() {
let err = validate_source_uri(" \t ").unwrap_err();
assert!(format!("{err}").contains("cannot be empty"));
}
#[test]
fn validate_source_uri_rejects_bare_string_without_scheme() {
let err = validate_source_uri("example.com/path").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("must start with"), "got: {msg}");
assert!(msg.contains("uri:") || msg.contains("doc:") || msg.contains("file:"));
}
#[test]
fn validate_source_uri_rejects_control_chars() {
let err = validate_source_uri("uri:has\x07ctrl").unwrap_err();
assert!(format!("{err}").contains("invalid control characters"));
}
#[test]
fn validate_source_uri_rejects_oversize_input() {
let big = format!("uri:{}", "a".repeat(8_000));
let err = validate_source_uri(&big).unwrap_err();
assert!(format!("{err}").contains("max length"));
}
#[test]
fn validate_source_uri_rejects_scheme_with_empty_payload() {
let err = validate_source_uri("doc:").unwrap_err();
assert!(format!("{err}").contains("empty payload"));
let err = validate_source_uri("file: ").unwrap_err();
assert!(format!("{err}").contains("empty payload"));
}
#[test]
fn validate_source_uri_accepts_three_known_schemes() {
assert!(validate_source_uri("uri:https://example.com").is_ok());
assert!(validate_source_uri("doc:abc-123").is_ok());
assert!(validate_source_uri("file:/etc/hosts").is_ok());
}
#[test]
fn validate_source_span_rejects_end_lt_start() {
let span = crate::models::SourceSpan { start: 10, end: 5 };
let err = validate_source_span(&span).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("start") && msg.contains("end"), "got: {msg}");
}
#[test]
fn validate_source_span_rejects_end_eq_start() {
let span = crate::models::SourceSpan { start: 4, end: 4 };
assert!(validate_source_span(&span).is_err());
}
#[test]
fn validate_source_span_accepts_valid_range() {
let span = crate::models::SourceSpan { start: 0, end: 10 };
assert!(validate_source_span(&span).is_ok());
}
#[test]
fn validate_source_span_for_body_rejects_end_gt_body_len() {
let body = "hello";
let span = crate::models::SourceSpan { start: 0, end: 10 };
let err = validate_source_span_for_body(&span, body).unwrap_err();
assert!(format!("{err}").contains("exceeds body length"));
}
#[test]
fn validate_source_span_for_body_rejects_non_char_boundary_start() {
let body = "é-pattern";
let span = crate::models::SourceSpan { start: 1, end: 3 };
let err = validate_source_span_for_body(&span, body).unwrap_err();
assert!(format!("{err}").contains("char boundary"));
}
#[test]
fn validate_source_span_for_body_rejects_non_char_boundary_end() {
let body = "aéb";
let span = crate::models::SourceSpan { start: 0, end: 2 };
let err = validate_source_span_for_body(&span, body).unwrap_err();
assert!(format!("{err}").contains("char boundary"));
}
#[test]
fn validate_source_span_for_body_accepts_full_body_slice() {
let body = "hello world";
let span = crate::models::SourceSpan {
start: 0,
end: body.len(),
};
assert!(validate_source_span_for_body(&span, body).is_ok());
}
#[test]
fn validate_citation_rejects_bad_uri() {
let mut c = good_citation();
c.uri = "bare-string-no-scheme".to_string();
let err = validate_citation(&c).unwrap_err();
assert!(format!("{err}").contains("must start with"));
}
#[test]
fn validate_citation_rejects_bad_accessed_at() {
let mut c = good_citation();
c.accessed_at = "not-a-date".to_string();
let err = validate_citation(&c).unwrap_err();
assert!(format!("{err}").contains("RFC3339"));
}
#[test]
fn validate_citation_rejects_short_hash() {
let mut c = good_citation();
c.hash = Some("deadbeef".to_string()); let err = validate_citation(&c).unwrap_err();
assert!(format!("{err}").contains("64 hex"));
}
#[test]
fn validate_citation_rejects_non_hex_hash() {
let mut c = good_citation();
c.hash = Some(format!("{}z", "a".repeat(63)));
let err = validate_citation(&c).unwrap_err();
assert!(format!("{err}").contains("64 hex"));
}
#[test]
fn validate_citation_accepts_valid_hash() {
let mut c = good_citation();
c.hash = Some("a".repeat(64));
assert!(validate_citation(&c).is_ok());
}
#[test]
fn validate_citation_propagates_span_rejection() {
let mut c = good_citation();
c.span = Some(crate::models::SourceSpan { start: 5, end: 1 });
let err = validate_citation(&c).unwrap_err();
assert!(format!("{err}").contains("source_span"));
}
#[test]
fn validate_citation_accepts_minimal_valid_form() {
assert!(validate_citation(&good_citation()).is_ok());
}
#[test]
fn validate_citations_rejects_count_over_cap() {
let many = vec![good_citation(); 65];
let err = validate_citations(&many).unwrap_err();
assert!(format!("{err}").contains("too many"));
}
#[test]
fn validate_citations_propagates_first_invalid_entry() {
let mut bad = good_citation();
bad.uri = "bogus".to_string();
let v = vec![good_citation(), bad];
let err = validate_citations(&v).unwrap_err();
assert!(format!("{err}").contains("must start with"));
}
#[test]
fn validate_citations_accepts_empty_and_full_under_cap() {
assert!(validate_citations(&[]).is_ok());
let v = vec![good_citation(); 64];
assert!(validate_citations(&v).is_ok());
}
fn happy_create() -> CreateMemory {
serde_json::from_value(serde_json::json!({
"title": "happy path",
"content": "memory body",
"namespace": "test-ns",
"tags": [],
"priority": 5,
"confidence": 0.5,
"source": "api",
"metadata": {}
}))
.expect("happy_create fixture deserialises")
}
#[test]
fn request_validator_validate_create_happy_path() {
let req = happy_create();
assert!(RequestValidator::validate_create(&req).is_ok());
}
#[test]
fn request_validator_validate_create_rejects_empty_title() {
let mut req = happy_create();
req.title = String::new();
let err = RequestValidator::validate_create(&req).expect_err("empty title must fail");
assert!(
err.reason.contains("title"),
"reason should mention `title`: {}",
err.reason
);
assert_eq!(err.field, "create");
}
#[test]
fn request_validator_validate_create_rejects_oob_confidence() {
let mut req = happy_create();
req.confidence = Some(2.0);
let err = RequestValidator::validate_create(&req)
.expect_err("oob confidence must fail validation");
assert!(
err.reason.contains("confidence") || err.reason.contains("between"),
"reason should mention confidence range: {}",
err.reason
);
}
#[test]
fn request_validator_validate_update_partial_ok() {
let req: UpdateMemory =
serde_json::from_value(serde_json::json!({})).expect("empty UpdateMemory deserialises");
assert!(RequestValidator::validate_update(&req).is_ok());
}
#[test]
fn request_validator_validate_update_rejects_oob_priority() {
let req: UpdateMemory = serde_json::from_value(serde_json::json!({
"priority": 99,
}))
.expect("oob-priority UpdateMemory deserialises");
let err =
RequestValidator::validate_update(&req).expect_err("priority=99 must fail validation");
assert!(
err.reason.contains("priority") || err.reason.contains("between"),
"reason should mention priority range: {}",
err.reason
);
}
#[test]
fn request_validator_validate_link_triple_happy_path() {
assert!(RequestValidator::validate_link_triple("a-id", "b-id", "related_to").is_ok(),);
}
#[test]
fn request_validator_validate_link_triple_rejects_self_link() {
let err = RequestValidator::validate_link_triple("same", "same", "related_to")
.expect_err("self-link must fail");
assert!(
err.reason.contains("itself") || err.reason.contains("self"),
"self-link must surface a typed reason: {}",
err.reason,
);
}
#[test]
fn request_validator_validate_link_triple_rejects_bad_relation() {
let err = RequestValidator::validate_link_triple("a", "b", "BAD-CASE-RELATION")
.expect_err("uppercase relation must fail");
assert!(
err.reason.contains("relation") || err.reason.contains("[a-z0-9_]"),
"reason should mention relation: {}",
err.reason,
);
}
#[test]
fn request_validator_validate_consolidate_rejects_under_two_ids() {
let err = RequestValidator::validate_consolidate(
&["only-one".to_string()],
"title",
"summary body",
"test-ns",
)
.expect_err("single id must fail");
assert!(
err.reason.contains("2"),
"reason should cite the 2-id min: {}",
err.reason
);
}
#[test]
fn request_validator_validate_id_and_namespace_bundles_both() {
assert!(RequestValidator::validate_id_and_namespace("an-id", "a-ns").is_ok());
let err = RequestValidator::validate_id_and_namespace("", "ok-ns")
.expect_err("empty id must fail");
assert_eq!(err.field, "id");
let err = RequestValidator::validate_id_and_namespace("ok-id", "")
.expect_err("empty namespace must fail");
assert_eq!(err.field, "namespace");
}
#[test]
fn request_validator_validate_owner_write_orders_id_ns_agent() {
assert!(RequestValidator::validate_owner_write("an-id", "a-ns", "alice").is_ok());
let err = RequestValidator::validate_owner_write("an-id", "a-ns", "daemon")
.expect_err("reserved agent_id must fail");
assert_eq!(err.field, "agent_id");
assert!(
err.reason.contains("reserved"),
"reserved-name reject must surface: {}",
err.reason,
);
}
#[test]
fn request_validator_validate_confidence_and_priority_bundles_both() {
assert!(RequestValidator::validate_confidence_and_priority(0.5, 5).is_ok());
let err = RequestValidator::validate_confidence_and_priority(2.0, 5)
.expect_err("oob confidence must fail");
assert_eq!(err.field, "confidence");
let err = RequestValidator::validate_confidence_and_priority(0.5, 99)
.expect_err("oob priority must fail");
assert_eq!(err.field, "priority");
}
#[test]
fn request_validator_validate_agent_id_rejects_reserved_sentinel() {
let err = RequestValidator::validate_agent_id("daemon")
.expect_err("reserved daemon agent_id must be rejected");
assert_eq!(err.field, "agent_id");
assert!(err.reason.contains("reserved"));
}
#[test]
fn validation_error_into_anyhow_preserves_reason() {
let ve = ValidationError::new("agent_id", "reserved for internal use");
let ae: anyhow::Error = ve.into();
assert!(format!("{ae}").contains("reserved for internal use"));
}
#[test]
fn validation_error_display_matches_legacy_bail_shape() {
let ve = ValidationError::new("namespace", "namespace cannot be empty");
assert_eq!(format!("{ve}"), "namespace cannot be empty");
}
}