Skip to main content

lean_ctx/core/context_package/
verify.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Standalone package verification (spec §8 integrity, §9 signing).
4//!
5//! `lean-ctx pack verify` and the import path share these primitives. All
6//! hashing operates on the *document text* of the content member — never on
7//! re-serialized parsed values, which would be lossy across languages
8//! (a writer's `1.0` re-serializes as `1` in JavaScript and breaks the hash).
9
10use 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
25/// Extract the exact text of one top-level member's value from a JSON object
26/// document, so integrity hashing sees the writer's bytes (spec §8).
27pub(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
31/// Kind ↔ payload coherence (GH #724/#726): the declared `kind` must match
32/// the content payload it ships, so a mislabeled package can never route
33/// into the wrong trust chain (an "addon" without an addon manifest, or a
34/// context pack smuggling one in).
35pub(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
1088/// Structural + integrity validation of a `kind=skills` payload (GH #727).
1089/// Every blob must decode and match its plaintext hash — a tampered body
1090/// fails verification, so it can never be materialized on disk.
1091fn 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
1136/// Path safety for document blobs: relative, `/`-separated, no traversal, no
1137/// absolute/drive/backslash forms — the materializer joins these under the
1138/// pack store and must never be able to escape it.
1139pub(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/// Outcome of one verification check.
1160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1161pub enum CheckOutcome {
1162    Pass,
1163    Fail,
1164    /// Not applicable — e.g. signature check on an unsigned package.
1165    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/// Per-check verification report, mirroring the checks every conforming
1180/// reader runs (and the shape of the @ctxpkg/verify reference output).
1181#[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    /// What a passing signature covers.
1190    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
1220/// Verify a `.ctxpkg` document without installing anything.
1221pub(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    // Kind ↔ payload coherence (GH #726) — a structural property: the
1255    // declared kind must match the payload the document actually carries.
1256    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    // §8 — integrity against the writer's bytes, recomputed exactly as the
1275    // hosted registry recomputes it.
1276    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    // §9 — a present-but-invalid signature is always tampering.
1313    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
1335/// Read and verify a `.ctxpkg` file (size- and extension-gated like import).
1336pub fn verify_package_file(path: &Path) -> Result<VerifyReport, String> {
1337    Ok(verify_package_text(&read_package_text(path)?))
1338}
1339
1340/// Read once so a caller can verify and decode the same bounded document.
1341pub(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;