1use sha2::{Digest, Sha256};
11use std::path::Path;
12
13use super::content::{
14 MAX_CHECKPOINT_ENTRIES, MAX_CHECKPOINT_PACKAGE_PINS, MAX_CHECKPOINT_REFS,
15 MAX_CHECKPOINT_SOURCES, PackageContent,
16};
17use super::manifest::{PackageKind, PackageLayer, PackageManifest};
18
19mod addon;
20mod checkpoint;
21mod text;
22use checkpoint::validate_checkpoint_content;
23pub(crate) use text::compact_json_text;
24
25pub(crate) fn extract_top_level_value_text<'a>(doc: &'a str, member: &str) -> Option<&'a str> {
28 crate::core::context_package::integrity::member_text(doc, member)
29}
30
31pub(crate) fn validate_kind_coherence(
36 manifest: &PackageManifest,
37 content: &PackageContent,
38) -> Result<(), Vec<String>> {
39 let mut errors = Vec::new();
40 match manifest.kind {
41 PackageKind::Addon => {
42 match &content.addon {
43 None => errors.push("kind=addon requires a content.addon payload".into()),
44 Some(addon) => addon::validate_addon(addon, &mut errors),
45 }
46 if content.documents.is_some() {
47 errors.push("content.documents payload requires kind=skills".into());
48 }
49 }
50 PackageKind::Skills => {
51 if content.addon.is_some() {
52 errors.push("content.addon payload requires kind=addon".into());
53 }
54 match &content.documents {
55 None => errors.push("kind=skills requires a content.documents payload".into()),
56 Some(docs) => validate_documents(docs, &mut errors),
57 }
58 }
59 PackageKind::Context | PackageKind::Grammar => {
60 if content.addon.is_some() {
61 errors.push(format!(
62 "content.addon payload requires kind=addon (manifest declares kind={})",
63 manifest.kind.as_str()
64 ));
65 }
66 if content.documents.is_some() {
67 errors.push(format!(
68 "content.documents payload requires kind=skills (manifest declares kind={})",
69 manifest.kind.as_str()
70 ));
71 }
72 }
73 }
74 let has_checkpoint_layer = manifest.has_layer(PackageLayer::Checkpoint);
75 if has_checkpoint_layer != content.checkpoint.is_some() {
76 errors.push(
77 "manifest checkpoint layer and content.checkpoint must be present together".into(),
78 );
79 }
80 if let Some(checkpoint) = &content.checkpoint {
81 if manifest.schema_version != crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION
82 || manifest.kind != PackageKind::Context
83 {
84 errors.push("checkpoint content requires schema_version 2 and kind=context".into());
85 }
86 validate_checkpoint_content(checkpoint, &mut errors);
87 }
88 if errors.is_empty() {
89 Ok(())
90 } else {
91 Err(errors)
92 }
93}
94
95fn validate_checkpoint_object(value: &serde_json::Value, errors: &mut Vec<String>) {
96 const KEYS: &[&str] = &[
97 "schema_version",
98 "checkpoint_id",
99 "workspace_id",
100 "state_digest",
101 "state_schema_version",
102 "workspace_state_ref",
103 "logical_state",
104 "source_anchors",
105 "recovery_refs",
106 "package_pins",
107 "package_lock_digest",
108 "policy_digest",
109 "project_context_digest",
110 "lineage",
111 "engine_identity",
112 "sdk_contract",
113 "envelope_digest",
114 ];
115 let Some(object) = value.as_object() else {
116 errors.push("checkpoint envelope must be an object".into());
117 return;
118 };
119 validate_exact_keys(object, KEYS, "checkpoint", errors);
120 if object
121 .get("schema_version")
122 .and_then(serde_json::Value::as_str)
123 != Some("leanctx.context-checkpoint/v2")
124 || object
125 .get("state_schema_version")
126 .and_then(serde_json::Value::as_str)
127 != Some("leanctx.workspace.state/v1")
128 || object
129 .get("sdk_contract")
130 .and_then(serde_json::Value::as_str)
131 != Some("leanctx-product-sdk-research/p6")
132 {
133 errors.push("checkpoint contract identity is unsupported".into());
134 }
135 for name in ["checkpoint_id", "workspace_id"] {
136 let valid = object
137 .get(name)
138 .and_then(serde_json::Value::as_str)
139 .and_then(|raw| uuid::Uuid::parse_str(raw).ok().map(|parsed| (raw, parsed)))
140 .is_some_and(|(raw, parsed)| parsed.hyphenated().to_string() == raw);
141 if !valid {
142 errors.push(format!("checkpoint.{name} must be a canonical UUID"));
143 }
144 }
145 for name in [
146 "state_digest",
147 "policy_digest",
148 "project_context_digest",
149 "envelope_digest",
150 ] {
151 if !object.get(name).is_some_and(valid_prefixed_digest) {
152 errors.push(format!("checkpoint.{name} must be a sha256 digest"));
153 }
154 }
155 if !object.get("workspace_state_ref").is_some_and(|item| {
156 item.as_str().is_some_and(|raw| {
157 raw.starts_with("event:sha256:")
158 && raw.len() == 77
159 && raw[13..]
160 .bytes()
161 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
162 })
163 }) {
164 errors.push("checkpoint.workspace_state_ref must bind an event digest".into());
165 }
166 if !object
167 .get("package_lock_digest")
168 .is_some_and(|item| item.is_null() || valid_prefixed_digest(item))
169 {
170 errors.push("checkpoint.package_lock_digest is invalid".into());
171 }
172
173 let arrays = [
174 ("source_anchors", MAX_CHECKPOINT_SOURCES),
175 ("recovery_refs", MAX_CHECKPOINT_REFS),
176 ("package_pins", MAX_CHECKPOINT_PACKAGE_PINS),
177 ];
178 for (name, cap) in arrays {
179 if object
180 .get(name)
181 .and_then(serde_json::Value::as_array)
182 .is_none_or(|items| items.len() > cap)
183 {
184 errors.push(format!("checkpoint.{name} is missing or exceeds cap {cap}"));
185 }
186 }
187 let Some(logical) = object
188 .get("logical_state")
189 .and_then(serde_json::Value::as_object)
190 else {
191 errors.push("checkpoint.logical_state must be an object".into());
192 return;
193 };
194 const LOGICAL_KEYS: &[&str] = &[
195 "schema_version",
196 "workspace_id",
197 "policy",
198 "sources",
199 "entries",
200 "package_pins",
201 "package_lock_digest",
202 ];
203 validate_exact_keys(logical, LOGICAL_KEYS, "checkpoint.logical_state", errors);
204 if logical
205 .get("schema_version")
206 .and_then(serde_json::Value::as_str)
207 != Some("leanctx.workspace.state/v1")
208 {
209 errors.push("checkpoint logical-state schema is unsupported".into());
210 }
211 validate_workspace_policy(logical.get("policy"), errors);
212 validate_lineage(object.get("lineage"), object.get("workspace_id"), errors);
213 validate_engine_identity(object.get("engine_identity"), errors);
214 if logical.get("workspace_id") != object.get("workspace_id")
215 || logical.get("sources") != object.get("source_anchors")
216 || logical.get("package_pins") != object.get("package_pins")
217 || logical.get("package_lock_digest") != object.get("package_lock_digest")
218 {
219 errors.push("checkpoint cross-field projections disagree".into());
220 }
221 if logical
222 .get("sources")
223 .and_then(serde_json::Value::as_array)
224 .is_none_or(|items| items.len() > MAX_CHECKPOINT_SOURCES)
225 || logical
226 .get("entries")
227 .and_then(serde_json::Value::as_array)
228 .is_none_or(|items| items.len() > MAX_CHECKPOINT_ENTRIES)
229 {
230 errors.push("checkpoint logical-state arrays exceed bounds".into());
231 }
232 let source_ids = logical
233 .get("sources")
234 .and_then(serde_json::Value::as_array)
235 .map(|sources| {
236 sources
237 .iter()
238 .filter_map(|source| source.get("source_id").and_then(serde_json::Value::as_str))
239 .collect::<std::collections::HashSet<_>>()
240 })
241 .unwrap_or_default();
242 if let Some(sources) = logical.get("sources").and_then(serde_json::Value::as_array) {
243 validate_source_anchors(sources, errors);
244 }
245 if let Some(entries) = logical.get("entries").and_then(serde_json::Value::as_array) {
246 validate_context_entries(entries, &source_ids, errors);
247 let mut expected_refs = entries
248 .iter()
249 .filter_map(|entry| {
250 entry
251 .get("recovery_refs")
252 .and_then(serde_json::Value::as_array)
253 })
254 .flatten()
255 .cloned()
256 .collect::<Vec<_>>();
257 expected_refs.sort_by(|left, right| left.as_str().cmp(&right.as_str()));
258 expected_refs.dedup();
259 if object
260 .get("recovery_refs")
261 .and_then(serde_json::Value::as_array)
262 != Some(&expected_refs)
263 {
264 errors.push("checkpoint recovery refs disagree with context entries".into());
265 }
266 }
267 if let Some(refs) = object
268 .get("recovery_refs")
269 .and_then(serde_json::Value::as_array)
270 {
271 validate_string_array(
272 refs,
273 MAX_CHECKPOINT_REFS,
274 512,
275 true,
276 "checkpoint recovery refs",
277 errors,
278 );
279 }
280 if let Some(pins) = logical
281 .get("package_pins")
282 .and_then(serde_json::Value::as_array)
283 {
284 validate_package_pins(pins, logical.get("package_lock_digest"), errors);
285 }
286 validate_domain_digest(
287 "leanctx.workspace.state.v1",
288 &serde_json::Value::Object(logical.clone()),
289 object.get("state_digest"),
290 "checkpoint.state_digest",
291 errors,
292 );
293 if let Some(policy) = logical.get("policy") {
294 validate_domain_digest(
295 "leanctx.workspace.policy.v1",
296 policy,
297 object.get("policy_digest"),
298 "checkpoint.policy_digest",
299 errors,
300 );
301 }
302 if let Some(entries) = logical.get("entries") {
303 validate_domain_digest(
304 "leanctx.project-context.state.v1",
305 entries,
306 object.get("project_context_digest"),
307 "checkpoint.project_context_digest",
308 errors,
309 );
310 }
311 let mut unsigned = object.clone();
312 unsigned.remove("envelope_digest");
313 validate_domain_digest(
314 "leanctx.checkpoint.envelope.v2",
315 &serde_json::Value::Object(unsigned),
316 object.get("envelope_digest"),
317 "checkpoint.envelope_digest",
318 errors,
319 );
320}
321
322fn validate_workspace_policy(value: Option<&serde_json::Value>, errors: &mut Vec<String>) {
323 const KEYS: &[&str] = &[
324 "schema_version",
325 "allowed_categories",
326 "max_events",
327 "max_context_entries",
328 "max_entry_bytes",
329 "max_context_bytes",
330 "max_sources",
331 "max_sessions",
332 "allow_external_sources",
333 ];
334 let Some(object) = value.and_then(serde_json::Value::as_object) else {
335 errors.push("checkpoint workspace policy must be an object".into());
336 return;
337 };
338 validate_exact_keys(object, KEYS, "checkpoint workspace policy", errors);
339 let Some(categories) = object
340 .get("allowed_categories")
341 .and_then(serde_json::Value::as_array)
342 else {
343 errors.push("checkpoint workspace policy categories must be an array".into());
344 return;
345 };
346 let category_values = categories
347 .iter()
348 .filter_map(serde_json::Value::as_str)
349 .collect::<Vec<_>>();
350 if object
351 .get("schema_version")
352 .and_then(serde_json::Value::as_str)
353 != Some("leanctx.workspace-policy/v1")
354 || category_values.len() != categories.len()
355 || !category_values.windows(2).all(|pair| pair[0] < pair[1])
356 || category_values.iter().any(|category| {
357 !matches!(
358 *category,
359 "facts" | "decisions" | "constraints" | "unresolved_questions" | "source_refs"
360 )
361 })
362 || [
363 "max_events",
364 "max_context_entries",
365 "max_entry_bytes",
366 "max_context_bytes",
367 "max_sources",
368 "max_sessions",
369 ]
370 .iter()
371 .any(|name| {
372 object
373 .get(*name)
374 .and_then(serde_json::Value::as_u64)
375 .is_none_or(|n| n == 0)
376 })
377 || !object
378 .get("allow_external_sources")
379 .is_some_and(serde_json::Value::is_boolean)
380 {
381 errors.push("checkpoint workspace policy is invalid".into());
382 }
383}
384
385fn validate_lineage(
386 value: Option<&serde_json::Value>,
387 workspace_id: Option<&serde_json::Value>,
388 errors: &mut Vec<String>,
389) {
390 let Some(object) = value.and_then(serde_json::Value::as_object) else {
391 errors.push("checkpoint lineage must be an object".into());
392 return;
393 };
394 validate_exact_keys(
395 object,
396 &["kind", "workspace_id", "state_id"],
397 "checkpoint lineage",
398 errors,
399 );
400 if object.get("kind").and_then(serde_json::Value::as_str) != Some("workspace")
401 || object.get("workspace_id") != workspace_id
402 || !object.get("state_id").is_some_and(valid_prefixed_digest)
403 {
404 errors.push("checkpoint lineage is invalid".into());
405 }
406}
407
408fn validate_engine_identity(value: Option<&serde_json::Value>, errors: &mut Vec<String>) {
409 let Some(object) = value.and_then(serde_json::Value::as_object) else {
410 errors.push("checkpoint engine identity must be an object".into());
411 return;
412 };
413 validate_exact_keys(
414 object,
415 &["interface_version", "schema_version", "transport_version"],
416 "checkpoint engine identity",
417 errors,
418 );
419 if object
420 .get("interface_version")
421 .and_then(serde_json::Value::as_str)
422 != Some("1.0.0")
423 || object
424 .get("schema_version")
425 .and_then(serde_json::Value::as_u64)
426 != Some(1)
427 || object
428 .get("transport_version")
429 .and_then(serde_json::Value::as_u64)
430 != Some(1)
431 {
432 errors.push("checkpoint engine identity is unsupported".into());
433 }
434}
435
436fn validate_source_anchors(sources: &[serde_json::Value], errors: &mut Vec<String>) {
437 const KEYS: &[&str] = &[
438 "schema_version",
439 "source_id",
440 "kind",
441 "canonical_id",
442 "revision",
443 "freshness",
444 "recovery",
445 "trust",
446 "scope",
447 "engine_binding",
448 ];
449 let mut source_ids = Vec::new();
450 for source in sources {
451 let Some(object) = source.as_object() else {
452 errors.push("checkpoint source anchor must be an object".into());
453 continue;
454 };
455 validate_exact_keys(object, KEYS, "checkpoint source anchor", errors);
456 if object
457 .get("schema_version")
458 .and_then(serde_json::Value::as_str)
459 != Some("leanctx.source-anchor/v1")
460 {
461 errors.push("checkpoint source anchor schema is unsupported".into());
462 }
463 let source_id = bounded_string(object.get("source_id"), 128);
464 let kind = object.get("kind").and_then(serde_json::Value::as_str);
465 if source_id.is_none()
466 || !matches!(
467 kind,
468 Some("filesystem" | "git" | "archive" | "api" | "custom")
469 )
470 || bounded_string(object.get("canonical_id"), 2048).is_none()
471 {
472 errors.push("checkpoint source anchor identity is invalid".into());
473 }
474 if let Some(source_id) = source_id {
475 source_ids.push(source_id);
476 }
477 validate_revision(object.get("revision"), kind, errors);
478 validate_freshness(object.get("freshness"), errors);
479 validate_recovery(object.get("recovery"), errors);
480 validate_trust(object.get("trust"), errors);
481 validate_pair_object(
482 object.get("scope"),
483 "checkpoint source scope",
484 64,
485 2048,
486 errors,
487 );
488 validate_engine_binding(object.get("engine_binding"), kind, errors);
489 }
490 if !source_ids.windows(2).all(|pair| pair[0] < pair[1]) {
491 errors.push("checkpoint source ids must be unique and sorted".into());
492 }
493}
494
495fn validate_revision(
496 value: Option<&serde_json::Value>,
497 source_kind: Option<&str>,
498 errors: &mut Vec<String>,
499) {
500 let Some(value) = value else { return };
501 if value.is_null() {
502 return;
503 }
504 let Some(object) = value.as_object() else {
505 errors.push("checkpoint source revision must be null or an object".into());
506 return;
507 };
508 validate_exact_keys(
509 object,
510 &["kind", "value"],
511 "checkpoint source revision",
512 errors,
513 );
514 if bounded_string(object.get("kind"), 64).is_none()
515 || bounded_string(object.get("value"), 2048).is_none()
516 || object.get("kind").and_then(serde_json::Value::as_str) != source_kind
517 {
518 errors.push("checkpoint source revision is invalid".into());
519 }
520}
521
522fn validate_freshness(value: Option<&serde_json::Value>, errors: &mut Vec<String>) {
523 let Some(object) = value.and_then(serde_json::Value::as_object) else {
524 errors.push("checkpoint source freshness must be an object".into());
525 return;
526 };
527 let keys = if object.contains_key("valid_until") {
528 &["observed_at", "status", "valid_until"][..]
529 } else {
530 &["observed_at", "status"][..]
531 };
532 validate_exact_keys(object, keys, "checkpoint source freshness", errors);
533 let observed_raw = object
534 .get("observed_at")
535 .and_then(serde_json::Value::as_str);
536 let observed = observed_raw.and_then(|raw| chrono::DateTime::parse_from_rfc3339(raw).ok());
537 let valid_until_raw = object
538 .get("valid_until")
539 .and_then(serde_json::Value::as_str);
540 let valid_until =
541 valid_until_raw.and_then(|raw| chrono::DateTime::parse_from_rfc3339(raw).ok());
542 if observed_raw.is_none_or(|raw| !canonical_utc_timestamp(raw))
543 || !object
544 .get("status")
545 .is_some_and(|value| matches!(value.as_str(), Some("current" | "stale" | "unknown")))
546 || (object.contains_key("valid_until")
547 && valid_until_raw.is_none_or(|raw| !canonical_utc_timestamp(raw)))
548 || valid_until
549 .zip(observed)
550 .is_some_and(|(valid, observed)| valid < observed)
551 {
552 errors.push("checkpoint source freshness is invalid".into());
553 }
554}
555
556fn validate_recovery(value: Option<&serde_json::Value>, errors: &mut Vec<String>) {
557 let Some(value) = value else { return };
558 if value.is_null() {
559 return;
560 }
561 let Some(object) = value.as_object() else {
562 errors.push("checkpoint source recovery must be null or an object".into());
563 return;
564 };
565 let keys = if object.contains_key("digest") {
566 &["kind", "immutable_ref", "digest"][..]
567 } else {
568 &["kind", "immutable_ref"][..]
569 };
570 validate_exact_keys(object, keys, "checkpoint source recovery", errors);
571 if bounded_string(object.get("kind"), 64).is_none()
572 || !object
573 .get("immutable_ref")
574 .and_then(serde_json::Value::as_str)
575 .is_some_and(|raw| valid_ref(raw, 2048))
576 || object
577 .get("digest")
578 .is_some_and(|value| !valid_prefixed_digest(value))
579 {
580 errors.push("checkpoint source recovery is invalid".into());
581 }
582}
583
584fn validate_trust(value: Option<&serde_json::Value>, errors: &mut Vec<String>) {
585 let Some(object) = value.and_then(serde_json::Value::as_object) else {
586 errors.push("checkpoint source trust must be an object".into());
587 return;
588 };
589 validate_exact_keys(
590 object,
591 &["level", "evidence_refs"],
592 "checkpoint source trust",
593 errors,
594 );
595 let level = object.get("level").and_then(serde_json::Value::as_str);
596 let Some(refs) = object
597 .get("evidence_refs")
598 .and_then(serde_json::Value::as_array)
599 else {
600 errors.push("checkpoint source trust evidence must be an array".into());
601 return;
602 };
603 validate_string_array(
604 refs,
605 32,
606 512,
607 true,
608 "checkpoint source trust evidence",
609 errors,
610 );
611 if !matches!(level, Some("unverified" | "local" | "verified"))
612 || (level == Some("verified")
613 && (refs.is_empty()
614 || refs.iter().any(|value| {
615 !value.as_str().is_some_and(|raw| {
616 raw.len() == 79
617 && raw.starts_with("receipt:sha256:")
618 && raw[15..]
619 .bytes()
620 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
621 })
622 })))
623 {
624 errors.push("checkpoint source trust is invalid".into());
625 }
626}
627
628fn validate_pair_object(
629 value: Option<&serde_json::Value>,
630 label: &str,
631 first_cap: usize,
632 second_cap: usize,
633 errors: &mut Vec<String>,
634) {
635 let Some(object) = value.and_then(serde_json::Value::as_object) else {
636 errors.push(format!("{label} must be an object"));
637 return;
638 };
639 validate_exact_keys(object, &["kind", "value"], label, errors);
640 if bounded_string(object.get("kind"), first_cap).is_none()
641 || bounded_string(object.get("value"), second_cap).is_none()
642 {
643 errors.push(format!("{label} is invalid"));
644 }
645}
646
647fn validate_engine_binding(
648 value: Option<&serde_json::Value>,
649 source_kind: Option<&str>,
650 errors: &mut Vec<String>,
651) {
652 let Some(value) = value else { return };
653 if value.is_null() {
654 return;
655 }
656 let Some(object) = value.as_object() else {
657 errors.push("checkpoint engine binding must be null or an object".into());
658 return;
659 };
660 if source_kind != Some("filesystem") {
661 errors.push("checkpoint engine binding requires a filesystem source".into());
662 }
663 let required = ["path", "project_root", "media_type"];
664 let allowed = [
665 "path",
666 "project_root",
667 "media_type",
668 "source_ref",
669 "source_digest",
670 ];
671 if object.keys().any(|key| !allowed.contains(&key.as_str()))
672 || required.iter().any(|key| !object.contains_key(*key))
673 || !object
674 .get("path")
675 .and_then(serde_json::Value::as_str)
676 .is_some_and(valid_relative_source_path)
677 || !object
678 .get("project_root")
679 .and_then(serde_json::Value::as_str)
680 .is_some_and(valid_normalized_project_root)
681 || bounded_string(object.get("media_type"), 512).is_none()
682 || object
683 .get("source_ref")
684 .is_some_and(|value| !value.as_str().is_some_and(|raw| valid_ref(raw, 512)))
685 || object
686 .get("source_digest")
687 .is_some_and(|value| !valid_prefixed_digest(value))
688 {
689 errors.push("checkpoint engine binding is invalid".into());
690 }
691}
692
693fn valid_relative_source_path(raw: &str) -> bool {
694 valid_text(raw, 4096)
695 && !is_absolute_path(raw)
696 && !raw.contains('\\')
697 && raw
698 .split('/')
699 .all(|component| !component.is_empty() && !matches!(component, "." | ".."))
700}
701
702fn valid_normalized_project_root(raw: &str) -> bool {
703 if !valid_text(raw, 4096) || !is_absolute_path(raw) {
704 return false;
705 }
706 let tail = if let Some(tail) = raw.strip_prefix("\\\\") {
707 tail
708 } else if let Some(tail) = raw.strip_prefix('/') {
709 tail
710 } else if raw.len() >= 3
711 && raw.as_bytes()[1] == b':'
712 && matches!(raw.as_bytes()[2], b'/' | b'\\')
713 {
714 &raw[3..]
715 } else {
716 return false;
717 };
718 tail.is_empty()
719 || tail
720 .split(['/', '\\'])
721 .all(|component| !component.is_empty() && !matches!(component, "." | ".."))
722}
723
724fn validate_context_entries(
725 entries: &[serde_json::Value],
726 source_ids: &std::collections::HashSet<&str>,
727 errors: &mut Vec<String>,
728) {
729 const KEYS: &[&str] = &[
730 "schema_version",
731 "entry_id",
732 "category",
733 "value",
734 "source_ids",
735 "session_id",
736 "receipt_refs",
737 "recovery_refs",
738 ];
739 let mut entry_ids = Vec::new();
740 for entry in entries {
741 let Some(object) = entry.as_object() else {
742 errors.push("checkpoint context entry must be an object".into());
743 continue;
744 };
745 validate_exact_keys(object, KEYS, "checkpoint context entry", errors);
746 if object
747 .get("schema_version")
748 .and_then(serde_json::Value::as_str)
749 != Some("leanctx.project-context-entry/v1")
750 || !object
751 .get("entry_id")
752 .and_then(serde_json::Value::as_str)
753 .is_some_and(|raw| {
754 uuid::Uuid::parse_str(raw)
755 .is_ok_and(|parsed| parsed.hyphenated().to_string() == raw)
756 })
757 || !object.get("category").is_some_and(|value| {
758 matches!(
759 value.as_str(),
760 Some(
761 "facts"
762 | "decisions"
763 | "constraints"
764 | "unresolved_questions"
765 | "source_refs"
766 )
767 )
768 })
769 || bounded_string(object.get("value"), 4096).is_none()
770 || object
771 .get("session_id")
772 .is_some_and(|value| !value.is_null() && bounded_string(Some(value), 512).is_none())
773 {
774 errors.push("checkpoint context entry identity/value is invalid".into());
775 }
776 if let Some(entry_id) = object.get("entry_id").and_then(serde_json::Value::as_str) {
777 entry_ids.push(entry_id);
778 }
779 for (field, cap, item_cap, printable) in [
780 ("source_ids", 16, 128, false),
781 ("receipt_refs", 16, 512, true),
782 ("recovery_refs", 16, 512, true),
783 ] {
784 let Some(items) = object.get(field).and_then(serde_json::Value::as_array) else {
785 errors.push(format!("checkpoint context entry {field} must be an array"));
786 continue;
787 };
788 validate_string_array(
789 items,
790 cap,
791 item_cap,
792 printable,
793 "checkpoint context entry refs",
794 errors,
795 );
796 if field == "source_ids"
797 && items
798 .iter()
799 .filter_map(serde_json::Value::as_str)
800 .any(|source_id| !source_ids.contains(source_id))
801 {
802 errors.push("checkpoint context entry references an unknown source".into());
803 }
804 }
805 }
806 if !entry_ids.windows(2).all(|pair| pair[0] < pair[1]) {
807 errors.push("checkpoint context entry ids must be unique and sorted".into());
808 }
809}
810
811fn validate_string_array(
812 values: &[serde_json::Value],
813 cap: usize,
814 item_cap: usize,
815 printable_ascii: bool,
816 label: &str,
817 errors: &mut Vec<String>,
818) {
819 if values.len() > cap
820 || values.iter().any(|value| {
821 bounded_string(Some(value), item_cap).is_none()
822 || (printable_ascii
823 && !value
824 .as_str()
825 .is_some_and(|raw| raw.bytes().all(|byte| (0x20..=0x7e).contains(&byte))))
826 })
827 || !values
828 .windows(2)
829 .all(|pair| pair[0].as_str() < pair[1].as_str())
830 {
831 errors.push(format!("{label} is invalid or exceeds its bound"));
832 }
833}
834
835fn valid_ref(raw: &str, cap: usize) -> bool {
836 !raw.is_empty() && raw.len() <= cap && raw.bytes().all(|byte| (0x20..=0x7e).contains(&byte))
837}
838
839fn canonical_utc_timestamp(raw: &str) -> bool {
840 (raw.len() == 20 || raw.len() == 27)
841 && raw.ends_with('Z')
842 && (raw.len() == 20 || raw.as_bytes().get(19) == Some(&b'.'))
843 && chrono::DateTime::parse_from_rfc3339(raw).is_ok()
844}
845
846fn bounded_string(value: Option<&serde_json::Value>, cap: usize) -> Option<&str> {
847 value
848 .and_then(serde_json::Value::as_str)
849 .filter(|raw| valid_text(raw, cap))
850}
851
852fn valid_text(raw: &str, cap: usize) -> bool {
853 !raw.is_empty() && raw.len() <= cap && !raw.chars().any(char::is_control)
854}
855
856fn validate_package_pins(
857 pins: &[serde_json::Value],
858 lock_digest: Option<&serde_json::Value>,
859 errors: &mut Vec<String>,
860) {
861 const KEYS: &[&str] = &[
862 "schema_version",
863 "name",
864 "version",
865 "artifact_digest",
866 "manifest_digest",
867 "content_hash",
868 "signature_state",
869 "signer_public_key",
870 "trust_state",
871 "policy_decision",
872 ];
873 let mut identities = Vec::new();
874 for pin in pins {
875 let Some(object) = pin.as_object() else {
876 errors.push("checkpoint package pin must be an object".into());
877 continue;
878 };
879 validate_exact_keys(object, KEYS, "checkpoint package pin", errors);
880 if object
881 .get("schema_version")
882 .and_then(serde_json::Value::as_str)
883 != Some("leanctx.package-pin/v1")
884 || object
885 .get("policy_decision")
886 .and_then(serde_json::Value::as_str)
887 != Some("admitted")
888 {
889 errors.push("checkpoint package pin contract is unsupported".into());
890 }
891 for name in ["artifact_digest", "manifest_digest", "content_hash"] {
892 if !object.get(name).is_some_and(valid_prefixed_digest) {
893 errors.push(format!("checkpoint package pin {name} is invalid"));
894 }
895 }
896 let signature = object
897 .get("signature_state")
898 .and_then(serde_json::Value::as_str);
899 let signer = object.get("signer_public_key");
900 if !matches!(signature, Some("signed_valid" | "unsigned"))
901 || (signature == Some("signed_valid")
902 && !signer.is_some_and(|value| {
903 value.as_str().is_some_and(|raw| {
904 raw.len() == 64
905 && raw
906 .bytes()
907 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
908 })
909 }))
910 || (signature == Some("unsigned") && !signer.is_some_and(serde_json::Value::is_null))
911 {
912 errors.push("checkpoint package pin signature identity is invalid".into());
913 }
914 if !object.get("trust_state").is_some_and(|value| {
915 matches!(value.as_str(), Some("trusted" | "untrusted" | "unknown"))
916 }) {
917 errors.push("checkpoint package pin trust state is invalid".into());
918 }
919 let Some(name) = object.get("name").and_then(serde_json::Value::as_str) else {
920 errors.push("checkpoint package pin name is invalid".into());
921 continue;
922 };
923 let Some(version) = object.get("version").and_then(serde_json::Value::as_str) else {
924 errors.push("checkpoint package pin version is invalid".into());
925 continue;
926 };
927 if !valid_text(name, 128) || !valid_text(version, 64) {
928 errors.push("checkpoint package pin name/version exceeds bounds".into());
929 }
930 identities.push((name, version));
931 }
932 if !identities.windows(2).all(|pair| pair[0] < pair[1]) {
933 errors.push("checkpoint package pins must be unique and sorted".into());
934 }
935 if pins.is_empty() {
936 if !lock_digest.is_some_and(serde_json::Value::is_null) {
937 errors.push("empty checkpoint package pins require a null lock digest".into());
938 }
939 } else {
940 validate_domain_digest(
941 "leanctx.package.lock.v1",
942 &serde_json::Value::Array(pins.to_vec()),
943 lock_digest,
944 "checkpoint.package_lock_digest",
945 errors,
946 );
947 }
948}
949
950fn validate_migration_provenance(
951 value: Option<&serde_json::Value>,
952 checkpoint: &serde_json::Value,
953 errors: &mut Vec<String>,
954) {
955 let Some(value) = value else { return };
956 const KEYS: &[&str] = &[
957 "origin",
958 "legacy_snapshot_id",
959 "legacy_snapshot_digest",
960 "migration_contract",
961 "checkpoint_id",
962 "state_digest",
963 "limitations",
964 ];
965 let Some(object) = value.as_object() else {
966 errors.push("migration_provenance must be an object".into());
967 return;
968 };
969 validate_exact_keys(object, KEYS, "migration_provenance", errors);
970 if object.get("origin").and_then(serde_json::Value::as_str) != Some("SnapshotV1")
971 || object
972 .get("migration_contract")
973 .and_then(serde_json::Value::as_str)
974 != Some("leanctx.snapshot-v1-migration/v1")
975 {
976 errors.push("SnapshotV1 migration provenance is unsupported".into());
977 }
978 for name in ["legacy_snapshot_digest", "state_digest"] {
979 if !object.get(name).is_some_and(valid_prefixed_digest) {
980 errors.push(format!("migration_provenance.{name} is invalid"));
981 }
982 }
983 if bounded_string(object.get("legacy_snapshot_id"), 512).is_none()
984 || !object
985 .get("limitations")
986 .and_then(serde_json::Value::as_array)
987 .is_some_and(|items| {
988 items.len() <= 64
989 && items
990 .iter()
991 .all(|item| bounded_string(Some(item), 2048).is_some())
992 })
993 {
994 errors.push("migration_provenance.limitations exceeds its bound".into());
995 }
996 if object.get("checkpoint_id") != checkpoint.get("checkpoint_id")
997 || object.get("state_digest") != checkpoint.get("state_digest")
998 {
999 errors.push("migration provenance is not bound to the carried checkpoint".into());
1000 }
1001}
1002
1003fn validate_exact_keys(
1004 object: &serde_json::Map<String, serde_json::Value>,
1005 expected: &[&str],
1006 label: &str,
1007 errors: &mut Vec<String>,
1008) {
1009 if object.len() != expected.len()
1010 || object.keys().any(|key| !expected.contains(&key.as_str()))
1011 || expected.iter().any(|key| !object.contains_key(*key))
1012 {
1013 errors.push(format!("{label} fields do not match the open contract"));
1014 }
1015}
1016
1017fn valid_prefixed_digest(value: &serde_json::Value) -> bool {
1018 value.as_str().is_some_and(|raw| {
1019 raw.len() == 71
1020 && raw.starts_with("sha256:")
1021 && raw[7..]
1022 .bytes()
1023 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1024 })
1025}
1026
1027fn validate_domain_digest(
1028 domain: &str,
1029 value: &serde_json::Value,
1030 claimed: Option<&serde_json::Value>,
1031 label: &str,
1032 errors: &mut Vec<String>,
1033) {
1034 let Ok(canonical) = serde_json::to_vec(value) else {
1035 errors.push(format!("{label} input is not canonical JSON"));
1036 return;
1037 };
1038 let mut hasher = Sha256::new();
1039 hasher.update(domain.as_bytes());
1040 hasher.update(b"\n");
1041 hasher.update(canonical);
1042 let expected = format!(
1043 "sha256:{}",
1044 crate::core::agent_identity::hex_encode(&hasher.finalize())
1045 );
1046 if claimed.and_then(serde_json::Value::as_str) != Some(expected.as_str()) {
1047 errors.push(format!("{label} does not match canonical content"));
1048 }
1049}
1050
1051fn collect_non_portable_paths(value: &serde_json::Value, pointer: &str, out: &mut Vec<String>) {
1052 match value {
1053 serde_json::Value::Object(object) => {
1054 for (key, item) in object {
1055 let child = format!("{pointer}.{key}");
1056 if matches!(key.as_str(), "path" | "project_root")
1057 && item.as_str().is_some_and(is_absolute_path)
1058 {
1059 out.push(child.clone());
1060 }
1061 if matches!(key.as_str(), "canonical_id" | "immutable_ref")
1062 && item.as_str().is_some_and(|value| {
1063 value.starts_with("file:///") || is_absolute_path(value)
1064 })
1065 {
1066 out.push(child.clone());
1067 }
1068 collect_non_portable_paths(item, &child, out);
1069 }
1070 }
1071 serde_json::Value::Array(items) => {
1072 for (index, item) in items.iter().enumerate() {
1073 collect_non_portable_paths(item, &format!("{pointer}[{index}]"), out);
1074 }
1075 }
1076 _ => {}
1077 }
1078}
1079
1080fn is_absolute_path(value: &str) -> bool {
1081 value.starts_with('/')
1082 || value.starts_with("\\\\")
1083 || (value.len() > 2
1084 && value.as_bytes()[1] == b':'
1085 && matches!(value.as_bytes()[2], b'/' | b'\\'))
1086}
1087
1088fn validate_documents(docs: &super::content::DocumentsContent, errors: &mut Vec<String>) {
1092 use super::content::{MAX_DOCUMENT_FILES, MAX_DOCUMENTS_TOTAL_BYTES};
1093
1094 if docs.files.is_empty() {
1095 errors.push("kind=skills payload has no files".into());
1096 return;
1097 }
1098 if docs.files.len() > MAX_DOCUMENT_FILES {
1099 errors.push(format!(
1100 "skills payload has {} files (cap: {MAX_DOCUMENT_FILES})",
1101 docs.files.len()
1102 ));
1103 return;
1104 }
1105
1106 let mut seen = std::collections::HashSet::new();
1107 let mut total: usize = 0;
1108 for blob in &docs.files {
1109 if let Err(e) = validate_document_path(&blob.path) {
1110 errors.push(e);
1111 continue;
1112 }
1113 if !seen.insert(blob.path.as_str()) {
1114 errors.push(format!("duplicate document path `{}`", blob.path));
1115 continue;
1116 }
1117 if blob.sha256.len() != 64 || !blob.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
1118 errors.push(format!(
1119 "`{}`: sha256 must be a 64-char hex string",
1120 blob.path
1121 ));
1122 continue;
1123 }
1124 match blob.decode_verified() {
1125 Ok(plain) => total += plain.len(),
1126 Err(e) => errors.push(e),
1127 }
1128 }
1129 if total > MAX_DOCUMENTS_TOTAL_BYTES {
1130 errors.push(format!(
1131 "skills payload decodes to {total} bytes (cap: {MAX_DOCUMENTS_TOTAL_BYTES})"
1132 ));
1133 }
1134}
1135
1136pub(crate) fn validate_document_path(path: &str) -> Result<(), String> {
1140 if path.is_empty() || path.len() > 512 {
1141 return Err(format!(
1142 "invalid document path `{path}` (empty or too long)"
1143 ));
1144 }
1145 if path.starts_with('/') || path.contains('\\') || path.contains(':') {
1146 return Err(format!(
1147 "invalid document path `{path}` (must be relative with `/` separators)"
1148 ));
1149 }
1150 let has_bad_component = path
1151 .split('/')
1152 .any(|c| c.is_empty() || c == "." || c == ".." || c.starts_with(".."));
1153 if has_bad_component || path.chars().any(char::is_control) {
1154 return Err(format!("invalid document path `{path}` (unsafe component)"));
1155 }
1156 Ok(())
1157}
1158
1159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1161pub enum CheckOutcome {
1162 Pass,
1163 Fail,
1164 Skipped,
1166}
1167
1168#[cfg(test)]
1169impl CheckOutcome {
1170 pub(crate) fn as_str(&self) -> &'static str {
1171 match self {
1172 Self::Pass => "pass",
1173 Self::Fail => "fail",
1174 Self::Skipped => "skipped",
1175 }
1176 }
1177}
1178
1179#[derive(Debug)]
1182pub struct VerifyReport {
1183 pub name: Option<String>,
1184 pub version: Option<String>,
1185 pub structure: CheckOutcome,
1186 pub content_hash: CheckOutcome,
1187 pub package_hash: CheckOutcome,
1188 pub signature: CheckOutcome,
1189 pub signature_coverage: Option<super::signing::SignatureCoverage>,
1191 pub errors: Vec<String>,
1192}
1193
1194impl VerifyReport {
1195 pub fn valid(&self) -> bool {
1196 self.errors.is_empty()
1197 }
1198
1199 fn failed(error: String) -> Self {
1200 Self {
1201 name: None,
1202 version: None,
1203 structure: CheckOutcome::Fail,
1204 content_hash: CheckOutcome::Skipped,
1205 package_hash: CheckOutcome::Skipped,
1206 signature: CheckOutcome::Skipped,
1207 signature_coverage: None,
1208 errors: vec![error],
1209 }
1210 }
1211}
1212
1213#[cfg(test)]
1214fn sha256_hex(data: &[u8]) -> String {
1215 let mut h = Sha256::new();
1216 h.update(data);
1217 crate::core::agent_identity::hex_encode(&h.finalize())
1218}
1219
1220pub(crate) fn verify_package_text(doc: &str) -> VerifyReport {
1222 let value: serde_json::Value = match serde_json::from_str(doc) {
1223 Ok(v) => v,
1224 Err(e) => return VerifyReport::failed(format!("not valid JSON: {e}")),
1225 };
1226
1227 let Some(manifest_value) = value.get("manifest") else {
1228 return VerifyReport::failed("missing required member: manifest".into());
1229 };
1230 if value.get("content").is_none() {
1231 return VerifyReport::failed("missing required member: content".into());
1232 }
1233
1234 let manifest: PackageManifest = match serde_json::from_value(manifest_value.clone()) {
1235 Ok(m) => m,
1236 Err(e) => return VerifyReport::failed(format!("manifest does not parse: {e}")),
1237 };
1238 let mut report = VerifyReport {
1239 name: Some(manifest.name.clone()),
1240 version: Some(manifest.version.clone()),
1241 structure: CheckOutcome::Pass,
1242 content_hash: CheckOutcome::Skipped,
1243 package_hash: CheckOutcome::Skipped,
1244 signature: CheckOutcome::Skipped,
1245 signature_coverage: None,
1246 errors: Vec::new(),
1247 };
1248 if let Err(errs) = manifest.validate() {
1249 report.structure = CheckOutcome::Fail;
1250 report.errors.extend(errs);
1251 return report;
1252 }
1253
1254 let content = match serde_json::from_value::<PackageContent>(
1257 value.get("content").cloned().unwrap_or_default(),
1258 ) {
1259 Ok(content) => content,
1260 Err(error) => {
1261 report.structure = CheckOutcome::Fail;
1262 report
1263 .errors
1264 .push(format!("content does not parse: {error}"));
1265 return report;
1266 }
1267 };
1268 if let Err(errs) = validate_kind_coherence(&manifest, &content) {
1269 report.structure = CheckOutcome::Fail;
1270 report.errors.extend(errs);
1271 return report;
1272 }
1273
1274 let Some(recomputed) = super::integrity::recompute(doc, &manifest.name, &manifest.version)
1277 else {
1278 report.structure = CheckOutcome::Fail;
1279 report
1280 .errors
1281 .push("could not locate the content member in the document".into());
1282 return report;
1283 };
1284
1285 if recomputed.content_hash == manifest.integrity.content_hash {
1286 report.content_hash = CheckOutcome::Pass;
1287 } else {
1288 report.content_hash = CheckOutcome::Fail;
1289 report.errors.push(format!(
1290 "content_hash mismatch: manifest says {}, content hashes to {}",
1291 manifest.integrity.content_hash, recomputed.content_hash
1292 ));
1293 }
1294 if manifest.integrity.byte_size != recomputed.byte_size {
1295 report.content_hash = CheckOutcome::Fail;
1296 report.errors.push(format!(
1297 "byte_size mismatch: manifest says {}, content is {} bytes",
1298 manifest.integrity.byte_size, recomputed.byte_size
1299 ));
1300 }
1301
1302 if recomputed.sha256 == manifest.integrity.sha256 {
1303 report.package_hash = CheckOutcome::Pass;
1304 } else {
1305 report.package_hash = CheckOutcome::Fail;
1306 report.errors.push(format!(
1307 "package sha256 mismatch: manifest says {}, recomputed {}",
1308 manifest.integrity.sha256, recomputed.sha256
1309 ));
1310 }
1311
1312 if manifest.signature.is_some() {
1314 match super::signing::signature_coverage_of(manifest_value) {
1315 Ok(Some(coverage)) => {
1316 report.signature = CheckOutcome::Pass;
1317 report.signature_coverage = Some(coverage);
1318 }
1319 Ok(None) => {
1320 report.signature = CheckOutcome::Fail;
1321 report.errors.push(
1322 "signature verification failed — the package was modified after signing".into(),
1323 );
1324 }
1325 Err(e) => {
1326 report.signature = CheckOutcome::Fail;
1327 report.errors.push(format!("signature check errored: {e}"));
1328 }
1329 }
1330 }
1331
1332 report
1333}
1334
1335pub fn verify_package_file(path: &Path) -> Result<VerifyReport, String> {
1337 Ok(verify_package_text(&read_package_text(path)?))
1338}
1339
1340pub(crate) fn read_package_text(path: &Path) -> Result<String, String> {
1342 use std::io::Read;
1343
1344 if !crate::core::contracts::is_package_file(path) {
1345 let ext = path
1346 .extension()
1347 .and_then(|e| e.to_str())
1348 .unwrap_or("(none)");
1349 return Err(format!(
1350 "unsupported file extension '.{ext}' — expected .{} or .{}",
1351 crate::core::contracts::PACKAGE_EXTENSION,
1352 crate::core::contracts::LEGACY_PACKAGE_EXTENSION,
1353 ));
1354 }
1355 let meta = std::fs::metadata(path).map_err(|e| format!("stat package file: {e}"))?;
1356 if !meta.is_file() {
1357 return Err("package input must be a regular file".into());
1358 }
1359 if meta.len() > crate::core::contracts::MAX_PACKAGE_FILE_BYTES {
1360 return Err(format!(
1361 "package file too large ({} bytes, max {} bytes)",
1362 meta.len(),
1363 crate::core::contracts::MAX_PACKAGE_FILE_BYTES,
1364 ));
1365 }
1366 let file = std::fs::File::open(path).map_err(|e| format!("open package file: {e}"))?;
1367 let mut doc = String::new();
1368 file.take(crate::core::contracts::MAX_PACKAGE_FILE_BYTES + 1)
1369 .read_to_string(&mut doc)
1370 .map_err(|e| format!("read package file: {e}"))?;
1371 if doc.len() as u64 > crate::core::contracts::MAX_PACKAGE_FILE_BYTES {
1372 return Err("package file exceeded size limit while reading".into());
1373 }
1374 Ok(doc)
1375}
1376
1377#[cfg(test)]
1378mod tests;