1use anyhow::{Result, bail};
5
6use crate::models::{
7 Citation, CreateMemory, MAX_CONTENT_SIZE, MAX_NAMESPACE_DEPTH, Memory, SourceSpan,
8 UpdateMemory, VALID_AGENT_TYPES, VALID_SCOPES,
9};
10
11const MAX_TITLE_LEN: usize = 512;
12const MAX_NAMESPACE_LEN: usize = 512;
16const MAX_SOURCE_LEN: usize = 64;
17const MAX_TAG_LEN: usize = 128;
18const MAX_TAGS_COUNT: usize = 50;
19const MAX_RELATION_LEN: usize = 64;
20const MAX_ID_LEN: usize = 128;
21const MAX_AGENT_ID_LEN: usize = 128;
22const MAX_AGENT_PUBKEY_B64_LEN: usize = 128;
27const MAX_METADATA_SIZE: usize = 65_536;
28const MAX_METADATA_DEPTH: usize = 32;
29
30pub(crate) const VALID_SOURCES: &[&str] = &[
55 "user",
56 "nhi",
58 "claude",
60 "hook",
61 "api",
62 "cli",
63 "import",
64 "consolidation",
65 "system",
66 "chaos",
67 "notify",
72];
73
74pub const DEFAULT_NHI_SOURCE: &str = "nhi";
91const VALID_RELATIONS: &[&str] = &[
122 crate::models::MemoryLinkRelation::RelatedTo.as_str(),
123 crate::models::MemoryLinkRelation::Supersedes.as_str(),
124 crate::models::MemoryLinkRelation::Contradicts.as_str(),
125 crate::models::MemoryLinkRelation::DerivedFrom.as_str(),
126 crate::models::MemoryLinkRelation::ReflectsOn.as_str(),
127 crate::models::MemoryLinkRelation::DerivesFrom.as_str(),
132];
133
134fn is_valid_rfc3339(s: &str) -> bool {
135 chrono::DateTime::parse_from_rfc3339(s).is_ok()
136}
137
138fn is_clean_string(s: &str) -> bool {
139 !s.chars().any(|c| c.is_control() && c != '\n' && c != '\t')
140}
141
142pub fn validate_title(title: &str) -> Result<()> {
143 let trimmed = title.trim();
144 if trimmed.is_empty() {
145 bail!("title cannot be empty");
146 }
147 if trimmed.chars().count() > MAX_TITLE_LEN {
148 bail!("title exceeds max length of {MAX_TITLE_LEN} characters");
149 }
150 if !is_clean_string(trimmed) {
151 bail!("title contains invalid characters");
152 }
153 Ok(())
154}
155
156pub fn validate_content(content: &str) -> Result<()> {
157 if content.trim().is_empty() {
158 bail!("content cannot be empty");
159 }
160 if content.len() > MAX_CONTENT_SIZE {
161 bail!("content exceeds max size of {MAX_CONTENT_SIZE} bytes");
162 }
163 if !is_clean_string(content) {
164 bail!("content contains invalid characters");
165 }
166 Ok(())
167}
168
169pub fn validate_namespace(ns: &str) -> Result<()> {
190 let trimmed = ns.trim();
191 if trimmed.is_empty() {
192 bail!("namespace cannot be empty");
193 }
194 if trimmed.chars().count() > MAX_NAMESPACE_LEN {
195 bail!("namespace exceeds max length of {MAX_NAMESPACE_LEN} characters");
196 }
197 if trimmed.contains('\\') || trimmed.contains('\0') {
198 bail!("namespace cannot contain backslashes or null bytes");
199 }
200 if trimmed.contains(' ') {
201 bail!("namespace cannot contain spaces (use hyphens or underscores)");
202 }
203 if !is_clean_string(trimmed) {
204 bail!("namespace contains invalid control characters");
205 }
206 if trimmed.starts_with('/') {
211 bail!("namespace cannot start with '/' (normalize input first)");
212 }
213 if trimmed.ends_with('/') {
214 bail!("namespace cannot end with '/' (normalize input first)");
215 }
216 if trimmed.split('/').any(str::is_empty) {
217 bail!("namespace cannot contain empty segments (e.g. '//')");
218 }
219 if trimmed.split('/').any(|s| s == ".." || s == ".") {
225 bail!("namespace segments '.' and '..' are not allowed");
226 }
227 let depth = crate::models::namespace_depth(trimmed);
228 if depth > MAX_NAMESPACE_DEPTH {
229 bail!("namespace depth {depth} exceeds max of {MAX_NAMESPACE_DEPTH}");
230 }
231 Ok(())
232}
233
234#[allow(dead_code)]
250#[must_use]
251pub fn normalize_namespace(input: &str) -> String {
252 let trimmed = input.trim();
253 let collapsed: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
254 collapsed.join("/").to_lowercase()
255}
256
257pub fn validate_source(source: &str) -> Result<()> {
258 if source.trim().is_empty() {
259 bail!("source cannot be empty");
260 }
261 if source.len() > MAX_SOURCE_LEN {
262 bail!("source exceeds max length of {MAX_SOURCE_LEN} bytes");
263 }
264 if !VALID_SOURCES.contains(&source) {
265 bail!(
266 "invalid source '{}' — must be one of: {}",
267 source,
268 VALID_SOURCES.join(", ")
269 );
270 }
271 Ok(())
272}
273
274pub const RESERVED_AGENT_IDS: &[&str] = &[
316 crate::identity::sentinels::DAEMON_PRINCIPAL,
317 crate::identity::sentinels::SYSTEM_PRINCIPAL,
318 crate::identity::sentinels::FEDERATION_CATCHUP,
319 crate::identity::sentinels::SUBSCRIPTION_DISPATCH,
320 crate::identity::sentinels::AI_HTTP_INTERNAL,
321 crate::identity::sentinels::AI_MIGRATE,
322 crate::identity::sentinels::EXPORT_INTERNAL,
323 crate::identity::sentinels::GOVERNANCE_INTERNAL,
324 crate::identity::sentinels::EMBEDDING_BACKFILL,
325];
326
327pub fn validate_agent_id_shape(agent_id: &str) -> Result<()> {
347 if agent_id.is_empty() {
348 bail!("agent_id cannot be empty");
349 }
350 if agent_id.len() > MAX_AGENT_ID_LEN {
351 bail!("agent_id exceeds max length of {MAX_AGENT_ID_LEN} bytes");
352 }
353 for c in agent_id.chars() {
354 if !(c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ':' | '@' | '.' | '/')) {
355 bail!("agent_id contains invalid character '{c}' (allowed: alphanumeric, _-:@./)");
356 }
357 }
358 if agent_id.contains("..") {
378 bail!("agent_id may not contain '..' (path-traversal guard)");
379 }
380 if agent_id.starts_with('/') {
381 bail!("agent_id may not start with '/' (path-traversal guard)");
382 }
383 Ok(())
384}
385
386pub fn validate_agent_id(agent_id: &str) -> Result<()> {
403 validate_agent_id_shape(agent_id)?;
404 if RESERVED_AGENT_IDS.contains(&agent_id) {
410 bail!(
411 "agent_id '{agent_id}' is reserved for internal use and cannot be supplied by wire \
412 callers"
413 );
414 }
415 Ok(())
416}
417
418pub fn validate_agent_pubkey_b64(pubkey_b64: &str) -> Result<()> {
440 let trimmed = pubkey_b64.trim();
441 if trimmed.is_empty() {
442 bail!("agent_pubkey cannot be empty");
443 }
444 if pubkey_b64.len() > MAX_AGENT_PUBKEY_B64_LEN {
445 bail!("agent_pubkey exceeds max length of {MAX_AGENT_PUBKEY_B64_LEN} bytes");
446 }
447 crate::identity::keypair::decode_public_base64(trimmed)
451 .map_err(|e| anyhow::anyhow!("agent_pubkey is not a valid Ed25519 public key: {e:#}"))?;
452 Ok(())
453}
454
455pub fn validate_scope(scope: &str) -> Result<()> {
460 if scope.is_empty() {
461 bail!("scope cannot be empty");
462 }
463 if !VALID_SCOPES.contains(&scope) {
464 bail!(
465 "invalid scope '{}' — must be one of: {}",
466 scope,
467 VALID_SCOPES.join(", ")
468 );
469 }
470 Ok(())
471}
472
473pub fn validate_governance_policy(policy: &crate::models::GovernancePolicy) -> Result<()> {
479 use crate::models::{ApproverType, GovernanceLevel};
480 match &policy.core.approver {
485 ApproverType::Human => {}
486 ApproverType::Agent(id) => {
487 validate_agent_id(id)?;
488 }
489 ApproverType::Consensus(n) => {
490 if *n == 0 {
491 bail!("governance.approver.consensus quorum must be >= 1");
492 }
493 }
494 }
495 let uses_approve = matches!(policy.core.write, GovernanceLevel::Approve)
499 || matches!(policy.core.promote, GovernanceLevel::Approve)
500 || matches!(policy.core.delete, GovernanceLevel::Approve);
501 if uses_approve
502 && let ApproverType::Consensus(n) = &policy.core.approver
503 && *n == 0
504 {
505 bail!("governance uses 'approve' level but approver consensus is 0");
506 }
507 Ok(())
508}
509
510const MAX_AGENT_TYPE_LEN: usize = 64;
512
513pub fn validate_agent_type(agent_type: &str) -> Result<()> {
526 if agent_type.is_empty() {
527 bail!("agent_type cannot be empty");
528 }
529 if agent_type.len() > MAX_AGENT_TYPE_LEN {
530 bail!("agent_type exceeds max length of {MAX_AGENT_TYPE_LEN} bytes");
531 }
532 if VALID_AGENT_TYPES.contains(&agent_type) {
534 return Ok(());
535 }
536 if let Some(name) = agent_type.strip_prefix("ai:") {
538 if name.is_empty() {
539 bail!("agent_type 'ai:' must include a name (e.g. 'ai:claude-opus-4.7')");
540 }
541 if name
542 .chars()
543 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
544 {
545 return Ok(());
546 }
547 bail!(
548 "agent_type '{agent_type}' contains invalid characters in the ai: name \
549 part (allowed: alphanumeric, _-.)"
550 );
551 }
552 let valid = VALID_AGENT_TYPES.join(", ");
553 bail!("invalid agent_type '{agent_type}' — must be one of: {valid} (or any ai:<name> form)");
554}
555
556pub fn validate_capabilities(caps: &[String]) -> Result<()> {
559 validate_tags(caps)
560}
561
562pub fn validate_tags(tags: &[String]) -> Result<()> {
563 if tags.len() > MAX_TAGS_COUNT {
564 bail!("too many tags (max {MAX_TAGS_COUNT})");
565 }
566 for tag in tags {
567 let trimmed = tag.trim();
568 if trimmed.is_empty() {
569 bail!("tags cannot contain empty strings");
570 }
571 if trimmed.len() > MAX_TAG_LEN {
572 let preview: String = trimmed.chars().take(20).collect();
573 bail!("tag '{preview}...' exceeds max length of {MAX_TAG_LEN} bytes");
574 }
575 if !is_clean_string(trimmed) {
576 bail!("tag contains invalid characters");
577 }
578 }
579 Ok(())
580}
581
582pub fn validate_id(id: &str) -> Result<()> {
583 if id.trim().is_empty() {
584 bail!("id cannot be empty");
585 }
586 if id.len() > MAX_ID_LEN {
587 bail!("id exceeds max length of {MAX_ID_LEN} bytes");
588 }
589 if !is_clean_string(id) {
590 bail!("id contains invalid characters");
591 }
592 if id.contains("..") {
602 bail!("id may not contain '..' (path-traversal guard)");
603 }
604 if id.contains('/') || id.contains('\\') {
605 bail!("id may not contain '/' or '\\' (path-traversal guard)");
606 }
607 if !id
610 .bytes()
611 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b':' | b'.' | b'@' | b'-'))
612 {
613 bail!(
614 "id contains characters outside the allowed set [A-Za-z0-9_:.@-] \
615 (path-traversal guard)"
616 );
617 }
618 Ok(())
619}
620
621pub fn validate_expires_at(expires_at: Option<&str>) -> Result<()> {
622 if let Some(ts) = expires_at {
623 if !is_valid_rfc3339(ts) {
624 bail!("expires_at is not valid RFC3339: '{ts}'");
625 }
626 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts)
627 && dt < chrono::Utc::now()
628 {
629 bail!("expires_at is in the past");
630 }
631 }
632 Ok(())
633}
634
635pub fn validate_ttl_secs(ttl: Option<i64>) -> Result<()> {
636 if let Some(secs) = ttl {
637 if secs <= 0 {
638 bail!("ttl_secs must be positive (got {secs})");
639 }
640 if secs > 365 * crate::SECS_PER_DAY {
641 bail!("ttl_secs exceeds maximum of 1 year");
642 }
643 }
644 Ok(())
645}
646
647pub fn validate_metadata(metadata: &serde_json::Value) -> Result<()> {
648 if !metadata.is_object() {
649 bail!("metadata must be a JSON object");
650 }
651 let serialized = serde_json::to_string(metadata)
652 .map_err(|e| anyhow::anyhow!("metadata is not valid JSON: {e}"))?;
653 if serialized.len() > MAX_METADATA_SIZE {
654 bail!(
655 "metadata exceeds max size of {MAX_METADATA_SIZE} bytes (got {})",
656 serialized.len()
657 );
658 }
659 let depth = json_depth(metadata);
660 if depth > MAX_METADATA_DEPTH {
661 bail!("metadata nesting depth exceeds limit of {MAX_METADATA_DEPTH} (got {depth})");
662 }
663 Ok(())
664}
665
666fn json_depth(val: &serde_json::Value) -> usize {
667 match val {
668 serde_json::Value::Object(map) => 1 + map.values().map(json_depth).max().unwrap_or(0),
669 serde_json::Value::Array(arr) => 1 + arr.iter().map(json_depth).max().unwrap_or(0),
670 _ => 0,
671 }
672}
673
674pub fn validate_relation(relation: &str) -> Result<()> {
675 if relation.trim().is_empty() {
676 bail!("relation cannot be empty");
677 }
678 if relation.len() > MAX_RELATION_LEN {
679 bail!("relation exceeds max length of {MAX_RELATION_LEN} bytes");
680 }
681 if VALID_RELATIONS.contains(&relation) {
690 return Ok(());
691 }
692 let ok = !relation.is_empty()
693 && relation
694 .chars()
695 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
696 if !ok {
697 bail!(
698 "invalid relation '{}' — must match [a-z0-9_]+ or be one of: {}",
699 relation,
700 VALID_RELATIONS.join(", ")
701 );
702 }
703 Ok(())
704}
705
706pub fn validate_confidence(confidence: f64) -> Result<()> {
707 if confidence.is_nan() || confidence.is_infinite() {
708 bail!("confidence must be a finite number");
709 }
710 if !(0.0..=1.0).contains(&confidence) {
711 bail!("confidence must be between 0.0 and 1.0 (got {confidence})");
712 }
713 Ok(())
714}
715
716pub fn validate_priority(priority: i32) -> Result<()> {
717 if !(1..=10).contains(&priority) {
718 bail!("priority must be between 1 and 10 (got {priority})");
719 }
720 Ok(())
721}
722
723const MAX_CITATIONS_PER_MEMORY: usize = 64;
729const MAX_SOURCE_URI_LEN: usize = 4_096;
733pub(crate) const VALID_SOURCE_URI_SCHEMES: &[&str] = &["uri:", "doc:", "file:"];
738
739pub fn validate_citation(c: &Citation) -> Result<()> {
754 validate_source_uri(&c.uri)?;
755 if !is_valid_rfc3339(&c.accessed_at) {
756 bail!(
757 "citation.accessed_at is not valid RFC3339: '{}'",
758 c.accessed_at
759 );
760 }
761 if let Some(ref h) = c.hash {
762 if h.len() != 64 || !h.chars().all(|ch| ch.is_ascii_hexdigit()) {
763 bail!("citation.hash must be 64 hex characters (SHA-256 digest)");
764 }
765 }
766 if let Some(ref span) = c.span {
767 validate_source_span(span)?;
768 }
769 Ok(())
770}
771
772pub fn validate_citations(citations: &[Citation]) -> Result<()> {
781 if citations.len() > MAX_CITATIONS_PER_MEMORY {
782 bail!(
783 "too many citations: {} exceeds cap of {MAX_CITATIONS_PER_MEMORY}",
784 citations.len()
785 );
786 }
787 for c in citations {
788 validate_citation(c)?;
789 }
790 Ok(())
791}
792
793pub fn validate_source_uri(s: &str) -> Result<()> {
810 let trimmed = s.trim();
811 if trimmed.is_empty() {
812 bail!("source URI cannot be empty");
813 }
814 if trimmed.len() > MAX_SOURCE_URI_LEN {
815 bail!("source URI exceeds max length of {MAX_SOURCE_URI_LEN} bytes");
816 }
817 if !is_clean_string(trimmed) {
818 bail!("source URI contains invalid control characters");
819 }
820 let matched = VALID_SOURCE_URI_SCHEMES
821 .iter()
822 .find(|prefix| trimmed.starts_with(*prefix));
823 match matched {
824 Some(prefix) => {
825 let payload = &trimmed[prefix.len()..];
826 if payload.trim().is_empty() {
827 bail!("source URI scheme '{prefix}' has empty payload");
828 }
829 Ok(())
830 }
831 None => bail!(
832 "source URI must start with one of: {}",
833 VALID_SOURCE_URI_SCHEMES.join(", ")
834 ),
835 }
836}
837
838pub fn validate_source_span(span: &SourceSpan) -> Result<()> {
849 if span.start >= span.end {
850 bail!(
851 "source_span requires start < end (got start={}, end={})",
852 span.start,
853 span.end
854 );
855 }
856 Ok(())
857}
858
859pub fn validate_source_span_for_body(span: &SourceSpan, body: &str) -> Result<()> {
881 validate_source_span(span)?;
882 if span.end > body.len() {
883 bail!(
884 "source_span end={} exceeds body length {}",
885 span.end,
886 body.len()
887 );
888 }
889 if !body.is_char_boundary(span.start) {
890 bail!(
891 "source_span start={} is not a UTF-8 char boundary in body",
892 span.start
893 );
894 }
895 if !body.is_char_boundary(span.end) {
896 bail!(
897 "source_span end={} is not a UTF-8 char boundary in body",
898 span.end
899 );
900 }
901 Ok(())
902}
903
904pub fn validate_create(mem: &CreateMemory) -> Result<()> {
906 validate_title(&mem.title)?;
907 validate_content(&mem.content)?;
908 validate_namespace(&mem.namespace)?;
909 validate_source(&mem.source)?;
910 validate_tags(&mem.tags)?;
911 validate_priority(mem.priority)?;
912 if let Some(confidence) = mem.confidence {
916 validate_confidence(confidence)?;
917 }
918 validate_expires_at(mem.expires_at.as_deref())?;
919 validate_ttl_secs(mem.ttl_secs)?;
920 validate_metadata(&mem.metadata)?;
921 validate_kind(mem.kind.as_deref())?;
927 validate_citations(&mem.citations)?;
930 if let Some(ref uri) = mem.source_uri {
931 validate_source_uri(uri)?;
932 }
933 if let Some(ref span) = mem.source_span {
934 validate_source_span(span)?;
935 }
936 Ok(())
937}
938
939pub fn validate_kind(kind: Option<&str>) -> Result<()> {
951 if let Some(s) = kind
952 && crate::models::MemoryKind::from_str(s).is_none()
953 {
954 let expected = crate::models::MemoryKind::all()
955 .iter()
956 .map(|k| k.as_str())
957 .collect::<Vec<_>>()
958 .join(", ");
959 bail!("invalid kind '{s}' (expected one of: {expected})");
960 }
961 Ok(())
962}
963
964pub fn validate_memory(mem: &Memory) -> Result<()> {
966 validate_id(&mem.id)?;
967 validate_title(&mem.title)?;
968 validate_content(&mem.content)?;
969 validate_namespace(&mem.namespace)?;
970 validate_source(&mem.source)?;
971 validate_tags(&mem.tags)?;
972 validate_priority(mem.priority)?;
973 validate_confidence(mem.confidence)?;
974 if mem.access_count < 0 {
975 bail!("access_count cannot be negative");
976 }
977 if !is_valid_rfc3339(&mem.created_at) {
978 bail!("created_at is not valid RFC3339");
979 }
980 if !is_valid_rfc3339(&mem.updated_at) {
981 bail!("updated_at is not valid RFC3339");
982 }
983 if let Some(ref ts) = mem.last_accessed_at
984 && !is_valid_rfc3339(ts)
985 {
986 bail!("last_accessed_at is not valid RFC3339");
987 }
988 if let Some(ref ts) = mem.expires_at
990 && !is_valid_rfc3339(ts)
991 {
992 bail!("expires_at is not valid RFC3339");
993 }
994 validate_metadata(&mem.metadata)?;
995 validate_citations(&mem.citations)?;
997 if let Some(ref uri) = mem.source_uri {
998 validate_source_uri(uri)?;
999 }
1000 if let Some(ref span) = mem.source_span {
1001 validate_source_span(span)?;
1002 }
1003 Ok(())
1004}
1005
1006pub fn validate_update(update: &UpdateMemory) -> Result<()> {
1010 if let Some(ref t) = update.title {
1011 validate_title(t)?;
1012 }
1013 if let Some(ref c) = update.content {
1014 validate_content(c)?;
1015 }
1016 if let Some(ref ns) = update.namespace {
1017 validate_namespace(ns)?;
1018 }
1019 if let Some(ref tags) = update.tags {
1020 validate_tags(tags)?;
1021 }
1022 if let Some(p) = update.priority {
1023 validate_priority(p)?;
1024 }
1025 if let Some(c) = update.confidence {
1026 validate_confidence(c)?;
1027 }
1028 if let Some(ref ts) = update.expires_at {
1029 validate_expires_at_format(ts)?;
1030 }
1031 if let Some(ref meta) = update.metadata {
1032 validate_metadata(meta)?;
1033 }
1034 if let Some(ref uri) = update.source_uri {
1035 validate_source_uri(uri)?;
1036 }
1037 Ok(())
1038}
1039
1040pub fn validate_expires_at_format(ts: &str) -> Result<()> {
1042 if !is_valid_rfc3339(ts) {
1043 bail!("expires_at is not valid RFC3339: '{ts}'");
1044 }
1045 Ok(())
1046}
1047
1048pub fn validate_link(source_id: &str, target_id: &str, relation: &str) -> Result<()> {
1050 validate_id(source_id)?;
1051 validate_id(target_id)?;
1052 validate_relation(relation)?;
1053 if source_id == target_id {
1054 bail!("cannot link a memory to itself");
1055 }
1056 Ok(())
1057}
1058
1059pub fn validate_consolidate(
1061 ids: &[String],
1062 title: &str,
1063 summary: &str,
1064 namespace: &str,
1065) -> Result<()> {
1066 if ids.len() < 2 {
1067 bail!("need at least 2 memory IDs to consolidate");
1068 }
1069 if ids.len() > 100 {
1070 bail!("cannot consolidate more than 100 memories at once");
1071 }
1072 let mut seen = std::collections::HashSet::new();
1073 for id in ids {
1074 validate_id(id)?;
1075 if !seen.insert(id) {
1076 bail!("duplicate memory ID: {id}");
1077 }
1078 }
1079 validate_title(title)?;
1080 validate_content(summary)?;
1081 validate_namespace(namespace)?;
1082 Ok(())
1083}
1084
1085#[derive(Debug, Clone, PartialEq, Eq)]
1120pub struct ValidationError {
1121 pub field: String,
1125 pub reason: String,
1129}
1130
1131impl ValidationError {
1132 #[must_use]
1135 pub fn new(field: impl Into<String>, reason: impl Into<String>) -> Self {
1136 Self {
1137 field: field.into(),
1138 reason: reason.into(),
1139 }
1140 }
1141
1142 fn from_anyhow(field: &str, err: anyhow::Error) -> Self {
1146 Self::new(field, err.to_string())
1147 }
1148}
1149
1150impl std::fmt::Display for ValidationError {
1151 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1152 write!(f, "{}", self.reason)
1156 }
1157}
1158
1159impl std::error::Error for ValidationError {}
1160
1161pub struct RequestValidator;
1212
1213impl RequestValidator {
1214 pub fn validate_create(req: &CreateMemory) -> Result<(), ValidationError> {
1223 validate_create(req).map_err(|e| ValidationError::from_anyhow("create", e))
1224 }
1225
1226 pub fn validate_update(req: &UpdateMemory) -> Result<(), ValidationError> {
1235 validate_update(req).map_err(|e| ValidationError::from_anyhow("update", e))
1236 }
1237
1238 pub fn validate_memory(req: &Memory) -> Result<(), ValidationError> {
1248 validate_memory(req).map_err(|e| ValidationError::from_anyhow("memory", e))
1249 }
1250
1251 pub fn validate_link_triple(
1258 source_id: &str,
1259 target_id: &str,
1260 relation: &str,
1261 ) -> Result<(), ValidationError> {
1262 validate_link(source_id, target_id, relation)
1263 .map_err(|e| ValidationError::from_anyhow("link", e))
1264 }
1265
1266 pub fn validate_consolidate(
1273 ids: &[String],
1274 title: &str,
1275 summary: &str,
1276 namespace: &str,
1277 ) -> Result<(), ValidationError> {
1278 validate_consolidate(ids, title, summary, namespace)
1279 .map_err(|e| ValidationError::from_anyhow("consolidate", e))
1280 }
1281
1282 pub fn validate_id(id: &str) -> Result<(), ValidationError> {
1290 validate_id(id).map_err(|e| ValidationError::from_anyhow("id", e))
1291 }
1292
1293 pub fn validate_namespace(ns: &str) -> Result<(), ValidationError> {
1299 validate_namespace(ns).map_err(|e| ValidationError::from_anyhow("namespace", e))
1300 }
1301
1302 pub fn validate_agent_id(agent_id: &str) -> Result<(), ValidationError> {
1309 validate_agent_id(agent_id).map_err(|e| ValidationError::from_anyhow("agent_id", e))
1310 }
1311
1312 pub fn validate_id_and_namespace(id: &str, ns: &str) -> Result<(), ValidationError> {
1322 Self::validate_id(id)?;
1323 Self::validate_namespace(ns)?;
1324 Ok(())
1325 }
1326
1327 pub fn validate_owner_write(id: &str, ns: &str, agent_id: &str) -> Result<(), ValidationError> {
1336 Self::validate_id(id)?;
1337 Self::validate_namespace(ns)?;
1338 Self::validate_agent_id(agent_id)?;
1339 Ok(())
1340 }
1341
1342 pub fn validate_confidence_and_priority(
1352 confidence: f64,
1353 priority: i32,
1354 ) -> Result<(), ValidationError> {
1355 validate_confidence(confidence)
1356 .map_err(|e| ValidationError::from_anyhow("confidence", e))?;
1357 validate_priority(priority).map_err(|e| ValidationError::from_anyhow("priority", e))?;
1358 Ok(())
1359 }
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364 use super::*;
1365
1366 #[test]
1367 fn test_valid_title() {
1368 assert!(validate_title("BIND9 custom build").is_ok());
1369 assert!(validate_title("").is_err());
1370 assert!(validate_title(" ").is_err());
1371 assert!(validate_title(&"x".repeat(513)).is_err());
1372 assert!(validate_title("has\0null").is_err());
1373 }
1374
1375 #[test]
1376 fn test_valid_namespace_flat_backwards_compat() {
1377 assert!(validate_namespace("my-project").is_ok());
1379 assert!(validate_namespace("global").is_ok());
1380 assert!(validate_namespace("under_score").is_ok());
1381 assert!(validate_namespace("ai-memory-mcp-dev").is_ok());
1382 assert!(validate_namespace("_agents").is_ok());
1383 }
1384
1385 #[test]
1386 fn test_valid_namespace_rejections_preserved() {
1387 assert!(validate_namespace("").is_err());
1388 assert!(validate_namespace(" ").is_err());
1389 assert!(validate_namespace("has space").is_err());
1390 assert!(validate_namespace("has\\backslash").is_err());
1391 assert!(validate_namespace("has\0null").is_err());
1392 assert!(validate_namespace("has\x07bell").is_err());
1393 }
1394
1395 #[test]
1396 fn test_namespace_rejects_dot_segments_redteam_240() {
1397 assert!(validate_namespace("acme/../other").is_err());
1400 assert!(validate_namespace("acme/./other").is_err());
1401 assert!(validate_namespace("..").is_err());
1402 assert!(validate_namespace(".").is_err());
1403 assert!(validate_namespace("acme/team/..").is_err());
1404 assert!(validate_namespace("../acme").is_err());
1405 assert!(validate_namespace("acme/team..special").is_ok());
1407 assert!(validate_namespace("acme/.dotfile").is_ok());
1408 }
1409
1410 #[test]
1411 fn test_namespace_length_bumped_to_512() {
1412 assert!(validate_namespace(&"x".repeat(128)).is_ok());
1414 assert!(validate_namespace(&"x".repeat(512)).is_ok());
1415 assert!(validate_namespace(&"x".repeat(513)).is_err());
1416 }
1417
1418 #[test]
1421 fn test_hierarchical_paths_accepted() {
1422 assert!(validate_namespace("alphaone/engineering").is_ok());
1423 assert!(validate_namespace("alphaone/engineering/platform").is_ok());
1424 assert!(validate_namespace("a/b/c/d/e/f/g/h").is_ok(), "8 levels OK");
1425 }
1426
1427 #[test]
1428 fn test_hierarchical_depth_cap() {
1429 assert!(validate_namespace("a/b/c/d/e/f/g/h/i").is_err());
1431 }
1432
1433 #[test]
1434 fn test_hierarchical_rejects_leading_slash() {
1435 assert!(validate_namespace("/alphaone/engineering").is_err());
1436 }
1437
1438 #[test]
1439 fn test_hierarchical_rejects_trailing_slash() {
1440 assert!(validate_namespace("alphaone/engineering/").is_err());
1441 }
1442
1443 #[test]
1444 fn test_hierarchical_rejects_empty_segments() {
1445 assert!(validate_namespace("alphaone//engineering").is_err());
1446 assert!(validate_namespace("a///b").is_err());
1447 }
1448
1449 #[test]
1450 fn test_hierarchical_rejects_control_chars() {
1451 assert!(validate_namespace("a/b\x07c").is_err());
1452 assert!(validate_namespace("a/b\0c").is_err());
1453 }
1454
1455 #[test]
1456 fn test_normalize_namespace_strips_slashes() {
1457 assert_eq!(
1458 normalize_namespace("/alphaone/engineering/"),
1459 "alphaone/engineering"
1460 );
1461 assert_eq!(normalize_namespace("///a///b///"), "a/b");
1462 }
1463
1464 #[test]
1465 fn test_normalize_namespace_lowercases() {
1466 assert_eq!(
1467 normalize_namespace("AlphaOne/Engineering"),
1468 "alphaone/engineering"
1469 );
1470 assert_eq!(normalize_namespace("MYAPP"), "myapp");
1471 }
1472
1473 #[test]
1474 fn test_normalize_namespace_trims_whitespace() {
1475 assert_eq!(normalize_namespace(" alphaone/eng "), "alphaone/eng");
1476 }
1477
1478 #[test]
1479 fn test_normalize_then_validate_roundtrip() {
1480 let raw = "/AlphaOne//Engineering/Platform/";
1481 let norm = normalize_namespace(raw);
1482 assert_eq!(norm, "alphaone/engineering/platform");
1483 assert!(validate_namespace(&norm).is_ok());
1484 }
1485
1486 #[test]
1487 fn test_valid_source() {
1488 assert!(validate_source("user").is_ok());
1489 assert!(validate_source("claude").is_ok());
1490 assert!(validate_source("hook").is_ok());
1491 assert!(validate_source("api").is_ok());
1492 assert!(validate_source("cli").is_ok());
1493 assert!(validate_source("import").is_ok());
1494 assert!(validate_source("").is_err());
1495 assert!(validate_source("random").is_err());
1496 }
1497
1498 #[test]
1499 fn test_valid_agent_id() {
1500 assert!(validate_agent_id("alice").is_ok());
1502 assert!(validate_agent_id("ai:claude-code@host-1:pid-123").is_ok());
1503 assert!(validate_agent_id("host:dev-1:pid-9-deadbeef").is_ok());
1504 assert!(validate_agent_id("anonymous:req-abcdef01").is_ok());
1505 assert!(validate_agent_id("anonymous:pid-42-0123abcd").is_ok());
1506 assert!(validate_agent_id("spiffe://example.org/ns/prod").is_ok());
1507 assert!(validate_agent_id("a").is_ok());
1508 assert!(validate_agent_id(&"a".repeat(128)).is_ok());
1509 }
1510
1511 #[test]
1512 fn test_invalid_agent_id() {
1513 assert!(validate_agent_id("").is_err());
1515 assert!(validate_agent_id(&"a".repeat(129)).is_err());
1516
1517 assert!(validate_agent_id("alice bob").is_err());
1519 assert!(validate_agent_id("alice\tbob").is_err());
1520 assert!(validate_agent_id(" alice").is_err());
1521 assert!(validate_agent_id("alice ").is_err());
1522
1523 assert!(validate_agent_id("has\0null").is_err());
1525 assert!(validate_agent_id("has\x07bell").is_err());
1526 assert!(validate_agent_id("has\nnewline").is_err());
1527
1528 assert!(validate_agent_id("alice;rm").is_err());
1530 assert!(validate_agent_id("alice|cat").is_err());
1531 assert!(validate_agent_id("alice&bg").is_err());
1532 assert!(validate_agent_id("alice$VAR").is_err());
1533 assert!(validate_agent_id("alice`cmd`").is_err());
1534 assert!(validate_agent_id("alice\\bs").is_err());
1535 assert!(validate_agent_id("alice?q").is_err());
1536 assert!(validate_agent_id("alice*glob").is_err());
1537 }
1538
1539 #[test]
1546 fn test_reserved_internal_agent_ids_rejected_977() {
1547 for &reserved in RESERVED_AGENT_IDS {
1548 let r = validate_agent_id(reserved);
1549 assert!(
1550 r.is_err(),
1551 "reserved agent_id '{reserved}' MUST be rejected on the wire (issue #977)",
1552 );
1553 let msg = r.unwrap_err().to_string();
1557 assert!(
1558 msg.contains("reserved for internal use"),
1559 "reserved-name reject must surface the dedicated reason; got: {msg}",
1560 );
1561 }
1562 }
1563
1564 #[test]
1568 fn test_legitimate_agent_ids_still_pass_after_977() {
1569 for legitimate in [
1572 "alice",
1573 "ai:claude-code@host-1:pid-123",
1574 "host:dev-1:pid-9-deadbeef",
1575 "anonymous:req-abcdef01",
1576 "anonymous:pid-42-0123abcd",
1577 "spiffe://example.org/ns/prod",
1578 "daemon-1",
1581 "system-admin",
1582 "ai:daemon-impostor",
1583 "federation-catchup-v2",
1584 "subscription-dispatch-replica",
1585 "ai:http-internal-shadow",
1586 "export-internal-tester",
1587 "governance-internal-audit",
1588 ] {
1589 assert!(
1590 validate_agent_id(legitimate).is_ok(),
1591 "legitimate NHI shape '{legitimate}' MUST still pass after #977",
1592 );
1593 }
1594 }
1595
1596 #[test]
1603 fn test_agent_id_rejects_path_traversal_1251() {
1604 for traversal in [
1606 "..",
1607 "../foo",
1608 "foo/..",
1609 "foo/../bar",
1610 "ai:claude/../etc",
1611 "host:..",
1612 "....", ] {
1614 let r = validate_agent_id_shape(traversal);
1615 assert!(
1616 r.is_err(),
1617 "path-traversal shape '{traversal}' must be rejected by validate_agent_id_shape",
1618 );
1619 let msg = r.unwrap_err().to_string();
1620 assert!(
1621 msg.contains("path-traversal") || msg.contains(".."),
1622 "reject message for '{traversal}' should cite path-traversal; got: {msg}",
1623 );
1624 }
1625
1626 let r = validate_agent_id_shape("/etc/keys");
1628 assert!(r.is_err(), "leading '/' agent_id must be rejected");
1629 assert!(
1630 r.unwrap_err().to_string().contains("path-traversal"),
1631 "leading '/' must cite path-traversal in the error",
1632 );
1633 }
1634
1635 #[test]
1640 fn test_agent_id_spiffe_still_ok_after_1251() {
1641 assert!(validate_agent_id_shape("spiffe://example.org/ns/prod").is_ok());
1642 assert!(validate_agent_id_shape("spiffe://a/b").is_ok());
1643 }
1644
1645 #[test]
1653 fn test_agent_pubkey_b64_accepts_generated_key() {
1654 let kp = crate::identity::keypair::generate("ai:curator").expect("generate");
1655 let b64 = kp.public_base64();
1656 assert!(
1657 validate_agent_pubkey_b64(&b64).is_ok(),
1658 "exported pubkey base64 must validate; got: {b64}",
1659 );
1660 let padded = format!(" {b64}\n");
1662 assert!(validate_agent_pubkey_b64(&padded).is_ok());
1663 }
1664
1665 #[test]
1668 fn test_agent_pubkey_b64_accepts_standard_padded() {
1669 use base64::Engine as _;
1670 let kp = crate::identity::keypair::generate("ai:curator").expect("generate");
1671 let padded = base64::engine::general_purpose::STANDARD.encode(kp.public.to_bytes());
1672 assert!(
1673 validate_agent_pubkey_b64(&padded).is_ok(),
1674 "standard-padded pubkey base64 must validate; got: {padded}",
1675 );
1676 }
1677
1678 #[test]
1679 fn test_agent_pubkey_b64_rejects_empty() {
1680 assert!(validate_agent_pubkey_b64("").is_err());
1681 assert!(validate_agent_pubkey_b64(" \n").is_err());
1682 }
1683
1684 #[test]
1685 fn test_agent_pubkey_b64_rejects_overlong() {
1686 let overlong = "A".repeat(MAX_AGENT_PUBKEY_B64_LEN + 1);
1687 let err = validate_agent_pubkey_b64(&overlong).unwrap_err();
1688 assert!(
1689 err.to_string().contains("max length"),
1690 "overlong pubkey must cite the length bound; got: {err}",
1691 );
1692 }
1693
1694 #[test]
1695 fn test_agent_pubkey_b64_rejects_malformed() {
1696 assert!(validate_agent_pubkey_b64("!!!not-base64!!!").is_err());
1698 use base64::Engine as _;
1700 let short = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 16]);
1701 let err = validate_agent_pubkey_b64(&short).unwrap_err();
1702 assert!(
1703 err.to_string().contains("not a valid Ed25519 public key"),
1704 "wrong-length key must surface the dedicated reason; got: {err}",
1705 );
1706 }
1707
1708 #[test]
1709 fn test_validate_governance_policy_default_ok() {
1710 let p = crate::models::GovernancePolicy::default();
1711 assert!(validate_governance_policy(&p).is_ok());
1712 }
1713
1714 #[test]
1720 fn test_validate_id_rejects_path_traversal_1051() {
1721 for bad in [
1722 "../etc/passwd",
1723 "..",
1724 "../../",
1725 "../../../tmp/evil",
1726 "foo/../bar",
1727 "foo/bar",
1728 "/foo",
1729 "foo/",
1730 "foo//bar",
1731 "foo\\bar",
1732 "C:\\Users\\foo",
1733 "foo bar", "rm -rf", "foo;rm", "..\\..\\evil", ] {
1738 assert!(
1739 validate_id(bad).is_err(),
1740 "validate_id('{bad}') must reject (path-traversal guard #1051)"
1741 );
1742 }
1743 }
1744
1745 #[test]
1746 fn test_validate_id_accepts_legitimate_ids_1051() {
1747 for ok in [
1748 "550e8400-e29b-41d4-a716-446655440000", "mem.abc123",
1750 "agent:claude-opus-4.7",
1751 "user@example.com",
1752 "namespace-foo_bar",
1753 "Mem_2026.05.21_xyz",
1754 ] {
1755 assert!(
1756 validate_id(ok).is_ok(),
1757 "validate_id('{ok}') must accept (legitimate id shape #1051)"
1758 );
1759 }
1760 }
1761
1762 #[test]
1763 fn test_validate_governance_consensus_zero_rejected() {
1764 use crate::models::{ApproverType, CorePolicy, GovernanceLevel, GovernancePolicy};
1765 let p = GovernancePolicy {
1766 core: CorePolicy {
1767 write: GovernanceLevel::Any,
1768 promote: GovernanceLevel::Any,
1769 delete: GovernanceLevel::Owner,
1770 approver: ApproverType::Consensus(0),
1771 inherit: true,
1772 max_reflection_depth: None,
1773 },
1774 ..Default::default()
1775 };
1776 assert!(validate_governance_policy(&p).is_err());
1777 }
1778
1779 #[test]
1780 fn test_validate_governance_agent_id_checked() {
1781 use crate::models::{ApproverType, CorePolicy, GovernanceLevel, GovernancePolicy};
1782 let bad = GovernancePolicy {
1783 core: CorePolicy {
1784 write: GovernanceLevel::Any,
1785 promote: GovernanceLevel::Any,
1786 delete: GovernanceLevel::Owner,
1787 approver: ApproverType::Agent("has space".to_string()),
1788 inherit: true,
1789 max_reflection_depth: None,
1790 },
1791 ..Default::default()
1792 };
1793 assert!(validate_governance_policy(&bad).is_err());
1794
1795 let good = GovernancePolicy {
1796 core: CorePolicy {
1797 write: GovernanceLevel::Any,
1798 promote: GovernanceLevel::Any,
1799 delete: GovernanceLevel::Owner,
1800 approver: ApproverType::Agent("alice".to_string()),
1801 inherit: true,
1802 max_reflection_depth: None,
1803 },
1804 ..Default::default()
1805 };
1806 assert!(validate_governance_policy(&good).is_ok());
1807 }
1808
1809 #[test]
1810 fn test_valid_scope() {
1811 for s in ["private", "team", "unit", "org", "collective"] {
1812 assert!(validate_scope(s).is_ok(), "{s} must be valid");
1813 }
1814 }
1815
1816 #[test]
1817 fn test_invalid_scope() {
1818 assert!(validate_scope("").is_err());
1819 assert!(validate_scope("public").is_err());
1820 assert!(validate_scope("PRIVATE").is_err());
1821 assert!(validate_scope("personal").is_err());
1822 }
1823
1824 #[test]
1825 fn test_valid_agent_type_curated_values() {
1826 assert!(validate_agent_type("ai:claude-opus-4.6").is_ok());
1827 assert!(validate_agent_type("ai:codex-5.4").is_ok());
1828 assert!(validate_agent_type("ai:grok-4.2").is_ok());
1829 assert!(validate_agent_type("human").is_ok());
1830 assert!(validate_agent_type("system").is_ok());
1831 }
1832
1833 #[test]
1834 fn test_valid_agent_type_open_ai_namespace_redteam_235() {
1835 assert!(validate_agent_type("ai:claude-opus-4.8").is_ok());
1838 assert!(validate_agent_type("ai:gpt-5").is_ok());
1839 assert!(validate_agent_type("ai:gemini-2.5").is_ok());
1840 assert!(validate_agent_type("ai:custom_internal-model.v2").is_ok());
1841 assert!(validate_agent_type("ai:claude").is_ok());
1842 }
1843
1844 #[test]
1845 fn test_invalid_agent_type() {
1846 assert!(validate_agent_type("").is_err());
1848 assert!(validate_agent_type("AI:CLAUDE").is_err());
1850 assert!(validate_agent_type("bogus").is_err());
1852 assert!(validate_agent_type("ai:").is_err());
1854 assert!(validate_agent_type("ai:foo bar").is_err());
1856 assert!(validate_agent_type("ai:foo;rm").is_err());
1857 assert!(validate_agent_type(&format!("ai:{}", "x".repeat(80))).is_err());
1859 }
1860
1861 #[test]
1862 fn test_agents_namespace_accepted() {
1863 assert!(validate_namespace("_agents").is_ok());
1864 }
1865
1866 #[test]
1867 fn test_valid_tags() {
1868 assert!(validate_tags(&["dns".to_string(), "bind9".to_string()]).is_ok());
1869 assert!(validate_tags(&[]).is_ok());
1870 assert!(validate_tags(&[String::new()]).is_err());
1871 let too_many: Vec<String> = (0..51).map(|i| format!("tag{i}")).collect();
1872 assert!(validate_tags(&too_many).is_err());
1873 }
1874
1875 #[test]
1876 fn test_valid_relation() {
1877 assert!(validate_relation("related_to").is_ok());
1899 assert!(validate_relation("derived_from").is_ok());
1900 assert!(validate_relation("contradicts").is_ok());
1901 assert!(validate_relation("supersedes").is_ok());
1902 assert!(validate_relation("reflects_on").is_ok());
1906
1907 assert!(validate_relation("s82_chain_marker").is_ok());
1910 assert!(validate_relation("invented_relation").is_ok());
1911 assert!(validate_relation("mentions").is_ok());
1912
1913 assert!(validate_relation("").is_err());
1915 assert!(validate_relation("BAD").is_err());
1916 assert!(validate_relation("bad relation").is_err());
1917 assert!(validate_relation("bad/relation").is_err());
1918 assert!(validate_relation("bad-relation").is_err());
1919 }
1920
1921 #[test]
1922 fn test_valid_confidence() {
1923 assert!(validate_confidence(0.0).is_ok());
1924 assert!(validate_confidence(0.5).is_ok());
1925 assert!(validate_confidence(1.0).is_ok());
1926 assert!(validate_confidence(-0.1).is_err());
1927 assert!(validate_confidence(1.1).is_err());
1928 assert!(validate_confidence(f64::NAN).is_err());
1929 assert!(validate_confidence(f64::INFINITY).is_err());
1930 }
1931
1932 #[test]
1933 fn test_valid_ttl() {
1934 assert!(validate_ttl_secs(None).is_ok());
1935 assert!(validate_ttl_secs(Some(crate::SECS_PER_HOUR)).is_ok());
1936 assert!(validate_ttl_secs(Some(0)).is_err());
1937 assert!(validate_ttl_secs(Some(-1)).is_err());
1938 assert!(validate_ttl_secs(Some(366 * crate::SECS_PER_DAY)).is_err());
1939 }
1940
1941 #[test]
1942 fn test_self_link_rejected() {
1943 assert!(validate_link("abc", "abc", "related_to").is_err());
1944 assert!(validate_link("abc", "def", "related_to").is_ok());
1945 }
1946
1947 #[test]
1948 fn test_valid_metadata() {
1949 assert!(validate_metadata(&serde_json::json!({})).is_ok());
1950 assert!(validate_metadata(&serde_json::json!({"key": "value"})).is_ok());
1951 assert!(validate_metadata(&serde_json::json!({"nested": {"a": 1}})).is_ok());
1952 assert!(validate_metadata(&serde_json::json!("string")).is_err());
1954 assert!(validate_metadata(&serde_json::json!(42)).is_err());
1955 assert!(validate_metadata(&serde_json::json!([1, 2])).is_err());
1956 assert!(validate_metadata(&serde_json::json!(null)).is_err());
1957 }
1958
1959 #[test]
1960 fn test_clean_string_rejects_control_chars() {
1961 assert!(is_clean_string("normal text"));
1962 assert!(is_clean_string("with\nnewline"));
1963 assert!(is_clean_string("with\ttab"));
1964 assert!(!is_clean_string("has\0null"));
1965 assert!(!is_clean_string("has\x07bell"));
1966 assert!(!is_clean_string("has\x1b[31mANSI\x1b[0m"));
1967 assert!(!is_clean_string("has\x08backspace"));
1968 }
1969
1970 #[test]
1971 fn test_oversized_metadata_rejected() {
1972 let big_value = "x".repeat(MAX_METADATA_SIZE);
1973 let meta = serde_json::json!({"big": big_value});
1974 assert!(validate_metadata(&meta).is_err());
1975 }
1976
1977 #[test]
1978 fn test_deeply_nested_metadata_rejected() {
1979 let mut val = serde_json::json!("leaf");
1981 for _ in 0..33 {
1982 val = serde_json::json!({"nested": val});
1983 }
1984 assert!(validate_metadata(&val).is_err());
1985
1986 let mut val = serde_json::json!("leaf");
1988 for _ in 0..31 {
1989 val = serde_json::json!({"nested": val});
1990 }
1991 assert!(validate_metadata(&val).is_ok());
1992 }
1993
1994 use proptest::prelude::*;
1998
1999 proptest! {
2000 #[test]
2002 fn prop_validate_title_rejects_empty_strings_only_when_actually_empty(
2003 ws in r"[ \t\n]{0,16}",
2004 tail in r"[A-Za-z0-9 _\-.,!?]{0,80}",
2005 ) {
2006 let title = format!("{ws}{tail}{ws}");
2008 let trimmed_empty = title.trim().is_empty();
2009 let result = validate_title(&title);
2010 if trimmed_empty {
2011 prop_assert!(result.is_err(), "whitespace-only title must reject: {:?}", title);
2012 } else if title.chars().count() <= 512 {
2013 prop_assert!(result.is_ok(), "non-empty trimmed title must accept: {:?}", title);
2014 }
2015 }
2016 }
2017
2018 proptest! {
2019 #[test]
2021 fn prop_validate_namespace_rejects_invalid_chars(
2022 base in r"[a-z][a-z0-9_-]{0,20}",
2023 bad in prop::sample::select(&[' ', '\\', '\0', '\x07', '\x1b', '\x08']),
2025 ) {
2026 let ns = format!("{base}{bad}suffix");
2027 prop_assert!(
2028 validate_namespace(&ns).is_err(),
2029 "namespace with bad char {:?} must reject: {:?}", bad, ns
2030 );
2031 }
2032 }
2033
2034 proptest! {
2035 #[test]
2037 fn prop_validate_namespace_accepts_valid_hierarchy(
2038 segs in prop::collection::vec(r"[a-z][a-z0-9_-]{0,20}", 1..=8),
2039 ) {
2040 let safe: Vec<String> = segs
2042 .into_iter()
2043 .filter(|s| s != "." && s != "..")
2044 .collect();
2045 if safe.is_empty() {
2046 return Ok(());
2047 }
2048 let ns = safe.join("/");
2049 prop_assert!(
2050 validate_namespace(&ns).is_ok(),
2051 "valid hierarchy must accept: {:?}", ns
2052 );
2053 }
2054 }
2055
2056 proptest! {
2057 #[test]
2059 fn prop_validate_priority_rejects_outside_range(p in -1000i32..1000i32) {
2060 let result = validate_priority(p);
2061 if (1..=10).contains(&p) {
2062 prop_assert!(result.is_ok(), "priority {p} (in 1..=10) must accept");
2063 } else {
2064 prop_assert!(result.is_err(), "priority {p} (outside 1..=10) must reject");
2065 }
2066 }
2067 }
2068
2069 proptest! {
2070 #[test]
2073 fn prop_validate_confidence_clamps_or_rejects(c in -10.0f64..10.0f64) {
2074 let result = validate_confidence(c);
2075 if (0.0..=1.0).contains(&c) {
2076 prop_assert!(result.is_ok(), "confidence {c} in [0,1] must accept");
2077 } else {
2078 prop_assert!(result.is_err(), "confidence {c} outside [0,1] must reject");
2079 }
2080 }
2081
2082 #[test]
2083 fn prop_validate_confidence_nan_inf_always_rejected(_u in Just(())) {
2084 prop_assert!(validate_confidence(f64::NAN).is_err());
2085 prop_assert!(validate_confidence(f64::INFINITY).is_err());
2086 prop_assert!(validate_confidence(f64::NEG_INFINITY).is_err());
2087 }
2088 }
2089
2090 proptest! {
2091 #[test]
2093 fn prop_validate_link_rejects_self_link_for_every_relation(
2094 id in r"[a-z][a-zA-Z0-9_-]{0,32}",
2095 rel_idx in 0usize..5,
2096 ) {
2097 let relations = [
2101 "related_to",
2102 "supersedes",
2103 "contradicts",
2104 "derived_from",
2105 "reflects_on",
2106 ];
2107 let rel = relations[rel_idx];
2108 let result = validate_link(&id, &id, rel);
2109 prop_assert!(result.is_err(), "self-link must reject for relation {rel}, id {:?}", id);
2110 }
2111 }
2112
2113 #[test]
2118 fn test_title_accepts_zero_width_joiner() {
2119 assert!(validate_title("emoji\u{200D}joiner").is_ok());
2121 }
2122
2123 #[test]
2124 fn test_title_accepts_rtl_marks() {
2125 assert!(validate_title("hello\u{200F}world").is_ok());
2127 assert!(validate_title("hello\u{200E}world").is_ok());
2128 }
2129
2130 #[test]
2131 fn test_title_accepts_combining_chars() {
2132 assert!(validate_title("cafe\u{0301}").is_ok());
2135 }
2136
2137 #[test]
2138 fn test_title_rejects_unicode_bom_as_control() {
2139 assert!(validate_title("foo\u{FEFF}bar").is_ok());
2143 }
2144
2145 #[test]
2152 fn content_with_control_chars_rejected() {
2153 let err = validate_content("has\x07bell").unwrap_err();
2155 let msg = format!("{err}");
2156 assert!(msg.contains("invalid characters"), "got: {msg}");
2157 }
2158
2159 #[test]
2160 fn content_with_null_byte_rejected() {
2161 let err = validate_content("has\0null").unwrap_err();
2162 assert!(format!("{err}").contains("invalid characters"));
2163 }
2164
2165 #[test]
2166 fn source_oversized_rejected() {
2167 let big = "x".repeat(65);
2169 let err = validate_source(&big).unwrap_err();
2170 let msg = format!("{err}");
2171 assert!(msg.contains("max length"), "got: {msg}");
2172 }
2173
2174 #[test]
2175 fn governance_approve_with_consensus_zero_rejected() {
2176 use crate::models::{ApproverType, CorePolicy, GovernanceLevel, GovernancePolicy};
2180 let p = GovernancePolicy {
2190 core: CorePolicy {
2191 write: GovernanceLevel::Approve,
2192 promote: GovernanceLevel::Any,
2193 delete: GovernanceLevel::Owner,
2194 approver: ApproverType::Consensus(0),
2195 inherit: true,
2196 max_reflection_depth: None,
2197 },
2198 ..Default::default()
2199 };
2200 assert!(validate_governance_policy(&p).is_err());
2201 }
2202
2203 #[test]
2204 fn tag_oversized_rejected_with_preview() {
2205 let big = "x".repeat(129);
2208 let tags = vec![big];
2209 let err = validate_tags(&tags).unwrap_err();
2210 let msg = format!("{err}");
2211 assert!(msg.contains("max length"), "got: {msg}");
2212 assert!(msg.contains("xxxxxxxxxxxxxxxxxxxx"), "got: {msg}");
2213 }
2214
2215 #[test]
2216 fn tag_with_control_chars_rejected() {
2217 let tags = vec!["has\x07bell".to_string()];
2219 let err = validate_tags(&tags).unwrap_err();
2220 assert!(format!("{err}").contains("invalid characters"));
2221 }
2222
2223 #[test]
2224 fn expires_at_malformed_rfc3339_rejected() {
2225 let err = validate_expires_at(Some("not-a-date")).unwrap_err();
2227 let msg = format!("{err}");
2228 assert!(msg.contains("RFC3339"), "got: {msg}");
2229 assert!(msg.contains("not-a-date"), "got: {msg}");
2230 }
2231
2232 #[test]
2233 fn expires_at_none_is_ok() {
2234 assert!(validate_expires_at(None).is_ok());
2236 }
2237
2238 #[test]
2239 fn expires_at_future_is_ok() {
2240 let future = "2099-01-01T00:00:00Z";
2242 assert!(validate_expires_at(Some(future)).is_ok());
2243 }
2244
2245 #[test]
2246 fn expires_at_past_rejected() {
2247 let past = "2000-01-01T00:00:00Z";
2249 let err = validate_expires_at(Some(past)).unwrap_err();
2250 assert!(format!("{err}").contains("past"));
2251 }
2252
2253 #[test]
2254 fn relation_oversized_rejected() {
2255 let big = "x".repeat(65);
2257 let err = validate_relation(&big).unwrap_err();
2258 let msg = format!("{err}");
2259 assert!(msg.contains("max length"), "got: {msg}");
2260 }
2261
2262 fn cm_valid() -> crate::models::CreateMemory {
2268 serde_json::from_value(serde_json::json!({
2271 "title": "ok title",
2272 "content": "ok content body",
2273 "namespace": "validate-test",
2274 "tags": ["one", "two"],
2275 "priority": 5,
2276 "confidence": 0.9,
2277 "source": "api",
2278 "metadata": {"k": "v"},
2279 }))
2280 .expect("fixture deserialises")
2281 }
2282
2283 #[test]
2284 fn validate_create_happy_path() {
2285 let m = cm_valid();
2286 assert!(validate_create(&m).is_ok());
2287 }
2288
2289 #[test]
2290 fn validate_create_propagates_title_error() {
2291 let mut m = cm_valid();
2292 m.title = String::new();
2293 assert!(validate_create(&m).is_err());
2294 }
2295
2296 #[test]
2297 fn validate_create_propagates_content_error() {
2298 let mut m = cm_valid();
2299 m.content = String::new();
2300 assert!(validate_create(&m).is_err());
2301 }
2302
2303 #[test]
2304 fn validate_create_propagates_namespace_error() {
2305 let mut m = cm_valid();
2306 m.namespace = "has space".to_string();
2307 assert!(validate_create(&m).is_err());
2308 }
2309
2310 #[test]
2311 fn validate_create_propagates_source_error() {
2312 let mut m = cm_valid();
2313 m.source = "bogus".to_string();
2314 assert!(validate_create(&m).is_err());
2315 }
2316
2317 #[test]
2320 fn validate_kind_none_is_ok() {
2321 assert!(validate_kind(None).is_ok());
2322 }
2323
2324 #[test]
2325 fn validate_kind_accepts_all_canonical_variants() {
2326 for k in crate::models::MemoryKind::all() {
2327 assert!(
2328 validate_kind(Some(k.as_str())).is_ok(),
2329 "canonical variant {:?} must validate",
2330 k.as_str()
2331 );
2332 }
2333 }
2334
2335 #[test]
2336 fn validate_kind_rejects_wrong_case_unknown_and_whitespace() {
2337 for bad in ["Claim", "bogus", "claim ", "OBSERVATION", ""] {
2338 let err = validate_kind(Some(bad)).unwrap_err().to_string();
2339 assert!(
2340 err.contains("invalid kind") && err.contains("expected one of"),
2341 "expected strict rejection for {bad:?}, got: {err}"
2342 );
2343 }
2344 }
2345
2346 #[test]
2347 fn validate_create_rejects_invalid_kind() {
2348 let mut m = cm_valid();
2349 m.kind = Some("bogus".to_string());
2350 assert!(validate_create(&m).is_err());
2351 }
2352
2353 #[test]
2354 fn validate_create_accepts_valid_kind() {
2355 let mut m = cm_valid();
2356 m.kind = Some("claim".to_string());
2357 assert!(validate_create(&m).is_ok());
2358 }
2359
2360 #[test]
2361 fn validate_create_propagates_tags_error() {
2362 let mut m = cm_valid();
2363 m.tags = vec![String::new()];
2364 assert!(validate_create(&m).is_err());
2365 }
2366
2367 #[test]
2368 fn validate_create_propagates_priority_error() {
2369 let mut m = cm_valid();
2370 m.priority = 11;
2371 assert!(validate_create(&m).is_err());
2372 }
2373
2374 #[test]
2375 fn validate_create_propagates_confidence_error() {
2376 let mut m = cm_valid();
2377 m.confidence = Some(1.5);
2378 assert!(validate_create(&m).is_err());
2379 m.confidence = None;
2382 assert!(validate_create(&m).is_ok());
2383 }
2384
2385 #[test]
2386 fn validate_create_propagates_expires_at_error() {
2387 let mut m = cm_valid();
2388 m.expires_at = Some("not-a-date".to_string());
2389 assert!(validate_create(&m).is_err());
2390 }
2391
2392 #[test]
2393 fn validate_create_propagates_ttl_error() {
2394 let mut m = cm_valid();
2395 m.ttl_secs = Some(-1);
2396 assert!(validate_create(&m).is_err());
2397 }
2398
2399 #[test]
2400 fn validate_create_propagates_metadata_error() {
2401 let mut m = cm_valid();
2402 m.metadata = serde_json::json!("not-an-object");
2403 assert!(validate_create(&m).is_err());
2404 }
2405
2406 fn mem_valid() -> crate::models::Memory {
2411 crate::models::Memory {
2412 id: "mem-1".to_string(),
2413 title: "ok title".to_string(),
2414 content: "ok content".to_string(),
2415 namespace: "validate-test".to_string(),
2416 source: "api".to_string(),
2417 tags: vec!["one".to_string()],
2418 priority: 5,
2419 confidence: 1.0,
2420 access_count: 0,
2421 created_at: "2026-01-01T00:00:00Z".to_string(),
2422 updated_at: "2026-01-01T00:00:00Z".to_string(),
2423 ..Default::default()
2424 }
2425 }
2426
2427 #[test]
2428 fn validate_memory_happy_path() {
2429 let m = mem_valid();
2430 assert!(validate_memory(&m).is_ok());
2431 }
2432
2433 #[test]
2434 fn validate_memory_rejects_empty_id() {
2435 let mut m = mem_valid();
2436 m.id = String::new();
2437 assert!(validate_memory(&m).is_err());
2438 }
2439
2440 #[test]
2441 fn validate_memory_rejects_negative_access_count() {
2442 let mut m = mem_valid();
2443 m.access_count = -1;
2444 let err = validate_memory(&m).unwrap_err();
2445 assert!(format!("{err}").contains("access_count"));
2446 }
2447
2448 #[test]
2449 fn validate_memory_rejects_malformed_created_at() {
2450 let mut m = mem_valid();
2451 m.created_at = "not-a-date".to_string();
2452 let err = validate_memory(&m).unwrap_err();
2453 assert!(format!("{err}").contains("created_at"));
2454 }
2455
2456 #[test]
2457 fn validate_memory_rejects_malformed_updated_at() {
2458 let mut m = mem_valid();
2459 m.updated_at = "not-a-date".to_string();
2460 let err = validate_memory(&m).unwrap_err();
2461 assert!(format!("{err}").contains("updated_at"));
2462 }
2463
2464 #[test]
2465 fn validate_memory_rejects_malformed_last_accessed_at() {
2466 let mut m = mem_valid();
2467 m.last_accessed_at = Some("not-a-date".to_string());
2468 let err = validate_memory(&m).unwrap_err();
2469 assert!(format!("{err}").contains("last_accessed_at"));
2470 }
2471
2472 #[test]
2473 fn validate_memory_accepts_valid_last_accessed_at() {
2474 let mut m = mem_valid();
2475 m.last_accessed_at = Some("2026-01-01T00:00:00Z".to_string());
2476 assert!(validate_memory(&m).is_ok());
2477 }
2478
2479 #[test]
2480 fn validate_memory_rejects_malformed_expires_at() {
2481 let mut m = mem_valid();
2482 m.expires_at = Some("not-a-date".to_string());
2483 let err = validate_memory(&m).unwrap_err();
2484 assert!(format!("{err}").contains("expires_at"));
2485 }
2486
2487 #[test]
2488 fn validate_memory_accepts_past_expires_at_for_import() {
2489 let mut m = mem_valid();
2491 m.expires_at = Some("2000-01-01T00:00:00Z".to_string());
2492 assert!(validate_memory(&m).is_ok());
2493 }
2494
2495 fn upd() -> crate::models::UpdateMemory {
2500 serde_json::from_value(serde_json::json!({})).expect("empty UpdateMemory deserialises")
2501 }
2502
2503 #[test]
2504 fn validate_update_empty_is_ok() {
2505 assert!(validate_update(&upd()).is_ok());
2506 }
2507
2508 #[test]
2509 fn validate_update_propagates_title_error() {
2510 let mut u = upd();
2511 u.title = Some(String::new());
2512 assert!(validate_update(&u).is_err());
2513 }
2514
2515 #[test]
2516 fn validate_update_propagates_content_error() {
2517 let mut u = upd();
2518 u.content = Some(String::new());
2519 assert!(validate_update(&u).is_err());
2520 }
2521
2522 #[test]
2523 fn validate_update_propagates_namespace_error() {
2524 let mut u = upd();
2525 u.namespace = Some("has space".to_string());
2526 assert!(validate_update(&u).is_err());
2527 }
2528
2529 #[test]
2530 fn validate_update_propagates_tags_error() {
2531 let mut u = upd();
2532 u.tags = Some(vec![String::new()]);
2533 assert!(validate_update(&u).is_err());
2534 }
2535
2536 #[test]
2537 fn validate_update_propagates_priority_error() {
2538 let mut u = upd();
2539 u.priority = Some(11);
2540 assert!(validate_update(&u).is_err());
2541 }
2542
2543 #[test]
2544 fn validate_update_propagates_confidence_error() {
2545 let mut u = upd();
2546 u.confidence = Some(2.0);
2547 assert!(validate_update(&u).is_err());
2548 }
2549
2550 #[test]
2551 fn validate_update_propagates_expires_at_format_error() {
2552 let mut u = upd();
2553 u.expires_at = Some("not-a-date".to_string());
2554 assert!(validate_update(&u).is_err());
2555 }
2556
2557 #[test]
2558 fn validate_update_allows_past_expires_at() {
2559 let mut u = upd();
2561 u.expires_at = Some("2000-01-01T00:00:00Z".to_string());
2562 assert!(validate_update(&u).is_ok());
2563 }
2564
2565 #[test]
2566 fn validate_update_propagates_metadata_error() {
2567 let mut u = upd();
2568 u.metadata = Some(serde_json::json!("not-an-object"));
2569 assert!(validate_update(&u).is_err());
2570 }
2571
2572 #[test]
2573 fn validate_expires_at_format_accepts_past_date() {
2574 assert!(validate_expires_at_format("2000-01-01T00:00:00Z").is_ok());
2576 assert!(validate_expires_at_format("not-a-date").is_err());
2577 }
2578
2579 #[test]
2584 fn consolidate_too_few_ids_rejected() {
2585 let err = validate_consolidate(&["only-one".to_string()], "title", "summary content", "ns")
2586 .unwrap_err();
2587 assert!(format!("{err}").contains("at least 2"));
2588 }
2589
2590 #[test]
2591 fn consolidate_too_many_ids_rejected() {
2592 let ids: Vec<String> = (0..101).map(|i| format!("id-{i}")).collect();
2593 let err = validate_consolidate(&ids, "title", "summary content", "ns").unwrap_err();
2594 assert!(format!("{err}").contains("100"));
2595 }
2596
2597 #[test]
2598 fn consolidate_duplicate_ids_rejected() {
2599 let ids = vec!["a".to_string(), "a".to_string()];
2600 let err = validate_consolidate(&ids, "title", "summary content", "ns").unwrap_err();
2601 assert!(format!("{err}").contains("duplicate"));
2602 }
2603
2604 #[test]
2605 fn consolidate_invalid_id_rejected() {
2606 let ids = vec!["valid".to_string(), String::new()];
2607 let err = validate_consolidate(&ids, "title", "summary content", "ns").unwrap_err();
2609 assert!(format!("{err}").contains("id"));
2610 }
2611
2612 #[test]
2613 fn consolidate_invalid_title_rejected() {
2614 let ids = vec!["a".to_string(), "b".to_string()];
2615 assert!(validate_consolidate(&ids, "", "summary content", "ns").is_err());
2616 }
2617
2618 #[test]
2619 fn consolidate_invalid_summary_rejected() {
2620 let ids = vec!["a".to_string(), "b".to_string()];
2621 assert!(validate_consolidate(&ids, "title", "", "ns").is_err());
2622 }
2623
2624 #[test]
2625 fn consolidate_invalid_namespace_rejected() {
2626 let ids = vec!["a".to_string(), "b".to_string()];
2627 assert!(validate_consolidate(&ids, "title", "summary content", "has space").is_err());
2628 }
2629
2630 #[test]
2631 fn consolidate_happy_path() {
2632 let ids = vec!["a".to_string(), "b".to_string(), "c".to_string()];
2633 assert!(validate_consolidate(&ids, "title", "summary content", "ns").is_ok());
2634 }
2635
2636 #[test]
2641 fn capabilities_delegates_to_tags() {
2642 assert!(validate_capabilities(&["read".to_string(), "write".to_string()]).is_ok());
2643 assert!(validate_capabilities(&[String::new()]).is_err());
2644 }
2645
2646 #[test]
2647 fn id_oversized_rejected() {
2648 let big = "a".repeat(129);
2649 let err = validate_id(&big).unwrap_err();
2650 assert!(format!("{err}").contains("max length"));
2651 }
2652
2653 #[test]
2654 fn id_with_control_chars_rejected() {
2655 let err = validate_id("has\0null").unwrap_err();
2656 assert!(format!("{err}").contains("invalid characters"));
2657 }
2658
2659 fn good_citation() -> crate::models::Citation {
2667 crate::models::Citation {
2668 uri: "doc:abc".to_string(),
2669 accessed_at: "2026-01-01T00:00:00Z".to_string(),
2670 hash: None,
2671 span: None,
2672 }
2673 }
2674
2675 #[test]
2676 fn validate_source_uri_rejects_empty_string() {
2677 let err = validate_source_uri("").unwrap_err();
2678 assert!(format!("{err}").contains("cannot be empty"));
2679 }
2680
2681 #[test]
2682 fn validate_source_uri_rejects_whitespace_only() {
2683 let err = validate_source_uri(" \t ").unwrap_err();
2684 assert!(format!("{err}").contains("cannot be empty"));
2685 }
2686
2687 #[test]
2688 fn validate_source_uri_rejects_bare_string_without_scheme() {
2689 let err = validate_source_uri("example.com/path").unwrap_err();
2690 let msg = format!("{err}");
2691 assert!(msg.contains("must start with"), "got: {msg}");
2692 assert!(msg.contains("uri:") || msg.contains("doc:") || msg.contains("file:"));
2693 }
2694
2695 #[test]
2696 fn validate_source_uri_rejects_control_chars() {
2697 let err = validate_source_uri("uri:has\x07ctrl").unwrap_err();
2698 assert!(format!("{err}").contains("invalid control characters"));
2699 }
2700
2701 #[test]
2702 fn validate_source_uri_rejects_oversize_input() {
2703 let big = format!("uri:{}", "a".repeat(8_000));
2704 let err = validate_source_uri(&big).unwrap_err();
2705 assert!(format!("{err}").contains("max length"));
2706 }
2707
2708 #[test]
2709 fn validate_source_uri_rejects_scheme_with_empty_payload() {
2710 let err = validate_source_uri("doc:").unwrap_err();
2711 assert!(format!("{err}").contains("empty payload"));
2712 let err = validate_source_uri("file: ").unwrap_err();
2713 assert!(format!("{err}").contains("empty payload"));
2714 }
2715
2716 #[test]
2717 fn validate_source_uri_accepts_three_known_schemes() {
2718 assert!(validate_source_uri("uri:https://example.com").is_ok());
2719 assert!(validate_source_uri("doc:abc-123").is_ok());
2720 assert!(validate_source_uri("file:/etc/hosts").is_ok());
2721 }
2722
2723 #[test]
2724 fn validate_source_span_rejects_end_lt_start() {
2725 let span = crate::models::SourceSpan { start: 10, end: 5 };
2726 let err = validate_source_span(&span).unwrap_err();
2727 let msg = format!("{err}");
2728 assert!(msg.contains("start") && msg.contains("end"), "got: {msg}");
2729 }
2730
2731 #[test]
2732 fn validate_source_span_rejects_end_eq_start() {
2733 let span = crate::models::SourceSpan { start: 4, end: 4 };
2735 assert!(validate_source_span(&span).is_err());
2736 }
2737
2738 #[test]
2739 fn validate_source_span_accepts_valid_range() {
2740 let span = crate::models::SourceSpan { start: 0, end: 10 };
2741 assert!(validate_source_span(&span).is_ok());
2742 }
2743
2744 #[test]
2745 fn validate_source_span_for_body_rejects_end_gt_body_len() {
2746 let body = "hello";
2747 let span = crate::models::SourceSpan { start: 0, end: 10 };
2748 let err = validate_source_span_for_body(&span, body).unwrap_err();
2749 assert!(format!("{err}").contains("exceeds body length"));
2750 }
2751
2752 #[test]
2753 fn validate_source_span_for_body_rejects_non_char_boundary_start() {
2754 let body = "é-pattern";
2757 let span = crate::models::SourceSpan { start: 1, end: 3 };
2758 let err = validate_source_span_for_body(&span, body).unwrap_err();
2759 assert!(format!("{err}").contains("char boundary"));
2760 }
2761
2762 #[test]
2763 fn validate_source_span_for_body_rejects_non_char_boundary_end() {
2764 let body = "aéb";
2765 let span = crate::models::SourceSpan { start: 0, end: 2 };
2766 let err = validate_source_span_for_body(&span, body).unwrap_err();
2767 assert!(format!("{err}").contains("char boundary"));
2768 }
2769
2770 #[test]
2771 fn validate_source_span_for_body_accepts_full_body_slice() {
2772 let body = "hello world";
2773 let span = crate::models::SourceSpan {
2774 start: 0,
2775 end: body.len(),
2776 };
2777 assert!(validate_source_span_for_body(&span, body).is_ok());
2778 }
2779
2780 #[test]
2781 fn validate_citation_rejects_bad_uri() {
2782 let mut c = good_citation();
2783 c.uri = "bare-string-no-scheme".to_string();
2784 let err = validate_citation(&c).unwrap_err();
2785 assert!(format!("{err}").contains("must start with"));
2786 }
2787
2788 #[test]
2789 fn validate_citation_rejects_bad_accessed_at() {
2790 let mut c = good_citation();
2791 c.accessed_at = "not-a-date".to_string();
2792 let err = validate_citation(&c).unwrap_err();
2793 assert!(format!("{err}").contains("RFC3339"));
2794 }
2795
2796 #[test]
2797 fn validate_citation_rejects_short_hash() {
2798 let mut c = good_citation();
2799 c.hash = Some("deadbeef".to_string()); let err = validate_citation(&c).unwrap_err();
2801 assert!(format!("{err}").contains("64 hex"));
2802 }
2803
2804 #[test]
2805 fn validate_citation_rejects_non_hex_hash() {
2806 let mut c = good_citation();
2807 c.hash = Some(format!("{}z", "a".repeat(63)));
2809 let err = validate_citation(&c).unwrap_err();
2810 assert!(format!("{err}").contains("64 hex"));
2811 }
2812
2813 #[test]
2814 fn validate_citation_accepts_valid_hash() {
2815 let mut c = good_citation();
2816 c.hash = Some("a".repeat(64));
2817 assert!(validate_citation(&c).is_ok());
2818 }
2819
2820 #[test]
2821 fn validate_citation_propagates_span_rejection() {
2822 let mut c = good_citation();
2823 c.span = Some(crate::models::SourceSpan { start: 5, end: 1 });
2824 let err = validate_citation(&c).unwrap_err();
2825 assert!(format!("{err}").contains("source_span"));
2826 }
2827
2828 #[test]
2829 fn validate_citation_accepts_minimal_valid_form() {
2830 assert!(validate_citation(&good_citation()).is_ok());
2831 }
2832
2833 #[test]
2834 fn validate_citations_rejects_count_over_cap() {
2835 let many = vec![good_citation(); 65];
2836 let err = validate_citations(&many).unwrap_err();
2837 assert!(format!("{err}").contains("too many"));
2838 }
2839
2840 #[test]
2841 fn validate_citations_propagates_first_invalid_entry() {
2842 let mut bad = good_citation();
2843 bad.uri = "bogus".to_string();
2844 let v = vec![good_citation(), bad];
2845 let err = validate_citations(&v).unwrap_err();
2846 assert!(format!("{err}").contains("must start with"));
2847 }
2848
2849 #[test]
2850 fn validate_citations_accepts_empty_and_full_under_cap() {
2851 assert!(validate_citations(&[]).is_ok());
2852 let v = vec![good_citation(); 64];
2853 assert!(validate_citations(&v).is_ok());
2854 }
2855
2856 fn happy_create() -> CreateMemory {
2861 serde_json::from_value(serde_json::json!({
2864 "title": "happy path",
2865 "content": "memory body",
2866 "namespace": "test-ns",
2867 "tags": [],
2868 "priority": 5,
2869 "confidence": 0.5,
2870 "source": "api",
2871 "metadata": {}
2872 }))
2873 .expect("happy_create fixture deserialises")
2874 }
2875
2876 #[test]
2877 fn request_validator_validate_create_happy_path() {
2878 let req = happy_create();
2881 assert!(RequestValidator::validate_create(&req).is_ok());
2882 }
2883
2884 #[test]
2885 fn request_validator_validate_create_rejects_empty_title() {
2886 let mut req = happy_create();
2889 req.title = String::new();
2890 let err = RequestValidator::validate_create(&req).expect_err("empty title must fail");
2891 assert!(
2892 err.reason.contains("title"),
2893 "reason should mention `title`: {}",
2894 err.reason
2895 );
2896 assert_eq!(err.field, "create");
2897 }
2898
2899 #[test]
2900 fn request_validator_validate_create_rejects_oob_confidence() {
2901 let mut req = happy_create();
2903 req.confidence = Some(2.0);
2904 let err = RequestValidator::validate_create(&req)
2905 .expect_err("oob confidence must fail validation");
2906 assert!(
2907 err.reason.contains("confidence") || err.reason.contains("between"),
2908 "reason should mention confidence range: {}",
2909 err.reason
2910 );
2911 }
2912
2913 #[test]
2914 fn request_validator_validate_update_partial_ok() {
2915 let req: UpdateMemory =
2918 serde_json::from_value(serde_json::json!({})).expect("empty UpdateMemory deserialises");
2919 assert!(RequestValidator::validate_update(&req).is_ok());
2920 }
2921
2922 #[test]
2923 fn request_validator_validate_update_rejects_oob_priority() {
2924 let req: UpdateMemory = serde_json::from_value(serde_json::json!({
2925 "priority": 99,
2926 }))
2927 .expect("oob-priority UpdateMemory deserialises");
2928 let err =
2929 RequestValidator::validate_update(&req).expect_err("priority=99 must fail validation");
2930 assert!(
2931 err.reason.contains("priority") || err.reason.contains("between"),
2932 "reason should mention priority range: {}",
2933 err.reason
2934 );
2935 }
2936
2937 #[test]
2938 fn request_validator_validate_link_triple_happy_path() {
2939 assert!(RequestValidator::validate_link_triple("a-id", "b-id", "related_to").is_ok(),);
2940 }
2941
2942 #[test]
2943 fn request_validator_validate_link_triple_rejects_self_link() {
2944 let err = RequestValidator::validate_link_triple("same", "same", "related_to")
2946 .expect_err("self-link must fail");
2947 assert!(
2948 err.reason.contains("itself") || err.reason.contains("self"),
2949 "self-link must surface a typed reason: {}",
2950 err.reason,
2951 );
2952 }
2953
2954 #[test]
2955 fn request_validator_validate_link_triple_rejects_bad_relation() {
2956 let err = RequestValidator::validate_link_triple("a", "b", "BAD-CASE-RELATION")
2957 .expect_err("uppercase relation must fail");
2958 assert!(
2959 err.reason.contains("relation") || err.reason.contains("[a-z0-9_]"),
2960 "reason should mention relation: {}",
2961 err.reason,
2962 );
2963 }
2964
2965 #[test]
2966 fn request_validator_validate_consolidate_rejects_under_two_ids() {
2967 let err = RequestValidator::validate_consolidate(
2968 &["only-one".to_string()],
2969 "title",
2970 "summary body",
2971 "test-ns",
2972 )
2973 .expect_err("single id must fail");
2974 assert!(
2975 err.reason.contains("2"),
2976 "reason should cite the 2-id min: {}",
2977 err.reason
2978 );
2979 }
2980
2981 #[test]
2982 fn request_validator_validate_id_and_namespace_bundles_both() {
2983 assert!(RequestValidator::validate_id_and_namespace("an-id", "a-ns").is_ok());
2985 let err = RequestValidator::validate_id_and_namespace("", "ok-ns")
2987 .expect_err("empty id must fail");
2988 assert_eq!(err.field, "id");
2989 let err = RequestValidator::validate_id_and_namespace("ok-id", "")
2991 .expect_err("empty namespace must fail");
2992 assert_eq!(err.field, "namespace");
2993 }
2994
2995 #[test]
2996 fn request_validator_validate_owner_write_orders_id_ns_agent() {
2997 assert!(RequestValidator::validate_owner_write("an-id", "a-ns", "alice").is_ok());
2999 let err = RequestValidator::validate_owner_write("an-id", "a-ns", "daemon")
3001 .expect_err("reserved agent_id must fail");
3002 assert_eq!(err.field, "agent_id");
3003 assert!(
3004 err.reason.contains("reserved"),
3005 "reserved-name reject must surface: {}",
3006 err.reason,
3007 );
3008 }
3009
3010 #[test]
3011 fn request_validator_validate_confidence_and_priority_bundles_both() {
3012 assert!(RequestValidator::validate_confidence_and_priority(0.5, 5).is_ok());
3013 let err = RequestValidator::validate_confidence_and_priority(2.0, 5)
3014 .expect_err("oob confidence must fail");
3015 assert_eq!(err.field, "confidence");
3016 let err = RequestValidator::validate_confidence_and_priority(0.5, 99)
3017 .expect_err("oob priority must fail");
3018 assert_eq!(err.field, "priority");
3019 }
3020
3021 #[test]
3022 fn request_validator_validate_agent_id_rejects_reserved_sentinel() {
3023 let err = RequestValidator::validate_agent_id("daemon")
3025 .expect_err("reserved daemon agent_id must be rejected");
3026 assert_eq!(err.field, "agent_id");
3027 assert!(err.reason.contains("reserved"));
3028 }
3029
3030 #[test]
3031 fn validation_error_into_anyhow_preserves_reason() {
3032 let ve = ValidationError::new("agent_id", "reserved for internal use");
3035 let ae: anyhow::Error = ve.into();
3036 assert!(format!("{ae}").contains("reserved for internal use"));
3037 }
3038
3039 #[test]
3040 fn validation_error_display_matches_legacy_bail_shape() {
3041 let ve = ValidationError::new("namespace", "namespace cannot be empty");
3045 assert_eq!(format!("{ve}"), "namespace cannot be empty");
3046 }
3047}