Skip to main content

lean_ctx/core/context_package/
verify.rs

1//! Standalone package verification (spec §8 integrity, §9 signing).
2//!
3//! `lean-ctx pack verify` and the import path share these primitives. All
4//! hashing operates on the *document text* of the content member — never on
5//! re-serialized parsed values, which would be lossy across languages
6//! (a writer's `1.0` re-serializes as `1` in JavaScript and breaks the hash).
7
8use sha2::{Digest, Sha256};
9use std::path::Path;
10
11use super::content::PackageContent;
12use super::manifest::{PackageKind, PackageManifest};
13
14/// Strip insignificant whitespace outside string literals (spec §8).
15pub(crate) fn compact_json_text(text: &str) -> String {
16    let mut out = String::with_capacity(text.len());
17    let mut chars = text.chars();
18    let mut in_string = false;
19    while let Some(ch) = chars.next() {
20        if in_string {
21            out.push(ch);
22            match ch {
23                '\\' => {
24                    if let Some(esc) = chars.next() {
25                        out.push(esc);
26                    }
27                }
28                '"' => in_string = false,
29                _ => {}
30            }
31        } else {
32            match ch {
33                '"' => {
34                    in_string = true;
35                    out.push(ch);
36                }
37                ' ' | '\t' | '\n' | '\r' => {}
38                _ => out.push(ch),
39            }
40        }
41    }
42    out
43}
44
45/// Extract the exact text of one top-level member's value from a JSON object
46/// document, so integrity hashing sees the writer's bytes (spec §8).
47pub(crate) fn extract_top_level_value_text<'a>(doc: &'a str, member: &str) -> Option<&'a str> {
48    let bytes = doc.as_bytes();
49    let n = bytes.len();
50    let mut i = 0;
51
52    let skip_ws = |i: &mut usize| {
53        while *i < n && matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r') {
54            *i += 1;
55        }
56    };
57    let skip_string = |i: &mut usize| {
58        *i += 1; // opening quote
59        while *i < n {
60            match bytes[*i] {
61                b'\\' => *i += 2,
62                b'"' => {
63                    *i += 1;
64                    return;
65                }
66                _ => *i += 1,
67            }
68        }
69    };
70    let skip_value = |i: &mut usize| {
71        skip_ws(i);
72        match bytes.get(*i) {
73            Some(b'"') => skip_string(i),
74            Some(&open @ (b'{' | b'[')) => {
75                let close = if open == b'{' { b'}' } else { b']' };
76                let mut depth = 0usize;
77                while *i < n {
78                    match bytes[*i] {
79                        b'"' => {
80                            skip_string(i);
81                            continue;
82                        }
83                        c if c == open => depth += 1,
84                        c if c == close => {
85                            depth -= 1;
86                            if depth == 0 {
87                                *i += 1;
88                                return;
89                            }
90                        }
91                        _ => {}
92                    }
93                    *i += 1;
94                }
95            }
96            _ => {
97                while *i < n
98                    && !matches!(bytes[*i], b',' | b'}' | b']' | b' ' | b'\t' | b'\n' | b'\r')
99                {
100                    *i += 1;
101                }
102            }
103        }
104    };
105
106    skip_ws(&mut i);
107    if bytes.get(i) != Some(&b'{') {
108        return None;
109    }
110    i += 1;
111    loop {
112        skip_ws(&mut i);
113        match bytes.get(i) {
114            Some(b'"') => {}
115            _ => return None,
116        }
117        let key_start = i;
118        skip_string(&mut i);
119        let key: String = serde_json::from_str(&doc[key_start..i]).ok()?;
120        skip_ws(&mut i);
121        if bytes.get(i) != Some(&b':') {
122            return None;
123        }
124        i += 1;
125        skip_ws(&mut i);
126        if key == member {
127            let start = i;
128            skip_value(&mut i);
129            return Some(&doc[start..i]);
130        }
131        skip_value(&mut i);
132        skip_ws(&mut i);
133        if bytes.get(i) == Some(&b',') {
134            i += 1;
135        }
136    }
137}
138
139/// Kind ↔ payload coherence (GH #724/#726): the declared `kind` must match
140/// the content payload it ships, so a mislabeled package can never route
141/// into the wrong trust chain (an "addon" without an addon manifest, or a
142/// context pack smuggling one in).
143pub fn validate_kind_coherence(
144    manifest: &PackageManifest,
145    content: &PackageContent,
146) -> Result<(), Vec<String>> {
147    let mut errors = Vec::new();
148    match manifest.kind {
149        PackageKind::Addon => match &content.addon {
150            None => errors.push("kind=addon requires a content.addon payload".into()),
151            Some(payload) => {
152                match crate::core::addons::manifest::AddonManifest::from_toml(
153                    &payload.manifest_toml,
154                ) {
155                    Err(e) => errors.push(format!("embedded addon manifest does not parse: {e}")),
156                    Ok(addon) => {
157                        if let Err(e) = addon.validate() {
158                            errors.push(format!("embedded addon manifest is invalid: {e}"));
159                        }
160                        // The pack is @ns/<slug>; the embedded manifest is the
161                        // slug's source of truth — they must agree, as must the
162                        // versions, or resolve-by-name breaks after install.
163                        let slug = manifest
164                            .name
165                            .rsplit('/')
166                            .next()
167                            .unwrap_or(manifest.name.as_str());
168                        if addon.addon.name != slug {
169                            errors.push(format!(
170                                "embedded addon name `{}` does not match the package name \
171                                 `{slug}`",
172                                addon.addon.name
173                            ));
174                        }
175                        if addon.addon.version != manifest.version {
176                            errors.push(format!(
177                                "embedded addon version `{}` does not match the package \
178                                 version `{}`",
179                                addon.addon.version, manifest.version
180                            ));
181                        }
182                        if !addon.is_installable() {
183                            errors.push(
184                                "embedded addon manifest has no runnable [mcp] endpoint".into(),
185                            );
186                        }
187                    }
188                }
189            }
190        },
191        PackageKind::Skills => {
192            if content.addon.is_some() {
193                errors.push("content.addon payload requires kind=addon".into());
194            }
195            match &content.documents {
196                None => errors.push("kind=skills requires a content.documents payload".into()),
197                Some(docs) => validate_documents(docs, &mut errors),
198            }
199        }
200        PackageKind::Context | PackageKind::Grammar => {
201            if content.addon.is_some() {
202                errors.push(format!(
203                    "content.addon payload requires kind=addon (manifest declares kind={})",
204                    manifest.kind.as_str()
205                ));
206            }
207            if content.documents.is_some() {
208                errors.push(format!(
209                    "content.documents payload requires kind=skills (manifest declares kind={})",
210                    manifest.kind.as_str()
211                ));
212            }
213        }
214    }
215    if errors.is_empty() {
216        Ok(())
217    } else {
218        Err(errors)
219    }
220}
221
222/// Structural + integrity validation of a `kind=skills` payload (GH #727).
223/// Every blob must decode and match its plaintext hash — a tampered body
224/// fails verification, so it can never be materialized on disk.
225fn validate_documents(docs: &super::content::DocumentsContent, errors: &mut Vec<String>) {
226    use super::content::{MAX_DOCUMENT_FILES, MAX_DOCUMENTS_TOTAL_BYTES};
227
228    if docs.files.is_empty() {
229        errors.push("kind=skills payload has no files".into());
230        return;
231    }
232    if docs.files.len() > MAX_DOCUMENT_FILES {
233        errors.push(format!(
234            "skills payload has {} files (cap: {MAX_DOCUMENT_FILES})",
235            docs.files.len()
236        ));
237        return;
238    }
239
240    let mut seen = std::collections::HashSet::new();
241    let mut total: usize = 0;
242    for blob in &docs.files {
243        if let Err(e) = validate_document_path(&blob.path) {
244            errors.push(e);
245            continue;
246        }
247        if !seen.insert(blob.path.as_str()) {
248            errors.push(format!("duplicate document path `{}`", blob.path));
249            continue;
250        }
251        if blob.sha256.len() != 64 || !blob.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
252            errors.push(format!(
253                "`{}`: sha256 must be a 64-char hex string",
254                blob.path
255            ));
256            continue;
257        }
258        match blob.decode_verified() {
259            Ok(plain) => total += plain.len(),
260            Err(e) => errors.push(e),
261        }
262    }
263    if total > MAX_DOCUMENTS_TOTAL_BYTES {
264        errors.push(format!(
265            "skills payload decodes to {total} bytes (cap: {MAX_DOCUMENTS_TOTAL_BYTES})"
266        ));
267    }
268}
269
270/// Path safety for document blobs: relative, `/`-separated, no traversal, no
271/// absolute/drive/backslash forms — the materializer joins these under the
272/// pack store and must never be able to escape it.
273pub(crate) fn validate_document_path(path: &str) -> Result<(), String> {
274    if path.is_empty() || path.len() > 512 {
275        return Err(format!(
276            "invalid document path `{path}` (empty or too long)"
277        ));
278    }
279    if path.starts_with('/') || path.contains('\\') || path.contains(':') {
280        return Err(format!(
281            "invalid document path `{path}` (must be relative with `/` separators)"
282        ));
283    }
284    let has_bad_component = path
285        .split('/')
286        .any(|c| c.is_empty() || c == "." || c == ".." || c.starts_with(".."));
287    if has_bad_component || path.chars().any(char::is_control) {
288        return Err(format!("invalid document path `{path}` (unsafe component)"));
289    }
290    Ok(())
291}
292
293/// Outcome of one verification check.
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub enum CheckOutcome {
296    Pass,
297    Fail,
298    /// Not applicable — e.g. signature check on an unsigned package.
299    Skipped,
300}
301
302impl CheckOutcome {
303    pub fn as_str(&self) -> &'static str {
304        match self {
305            Self::Pass => "pass",
306            Self::Fail => "fail",
307            Self::Skipped => "skipped",
308        }
309    }
310}
311
312/// Per-check verification report, mirroring the checks every conforming
313/// reader runs (and the shape of the @ctxpkg/verify reference output).
314#[derive(Debug)]
315pub struct VerifyReport {
316    pub name: Option<String>,
317    pub version: Option<String>,
318    pub structure: CheckOutcome,
319    pub content_hash: CheckOutcome,
320    pub package_hash: CheckOutcome,
321    pub signature: CheckOutcome,
322    pub errors: Vec<String>,
323}
324
325impl VerifyReport {
326    pub fn valid(&self) -> bool {
327        self.errors.is_empty()
328    }
329
330    fn failed(error: String) -> Self {
331        Self {
332            name: None,
333            version: None,
334            structure: CheckOutcome::Fail,
335            content_hash: CheckOutcome::Skipped,
336            package_hash: CheckOutcome::Skipped,
337            signature: CheckOutcome::Skipped,
338            errors: vec![error],
339        }
340    }
341}
342
343fn sha256_hex(data: &[u8]) -> String {
344    let mut h = Sha256::new();
345    h.update(data);
346    crate::core::agent_identity::hex_encode(&h.finalize())
347}
348
349/// Verify a `.ctxpkg` document without installing anything.
350pub fn verify_package_text(doc: &str) -> VerifyReport {
351    let value: serde_json::Value = match serde_json::from_str(doc) {
352        Ok(v) => v,
353        Err(e) => return VerifyReport::failed(format!("not valid JSON: {e}")),
354    };
355
356    let Some(manifest_value) = value.get("manifest") else {
357        return VerifyReport::failed("missing required member: manifest".into());
358    };
359    if value.get("content").is_none() {
360        return VerifyReport::failed("missing required member: content".into());
361    }
362
363    let manifest: PackageManifest = match serde_json::from_value(manifest_value.clone()) {
364        Ok(m) => m,
365        Err(e) => return VerifyReport::failed(format!("manifest does not parse: {e}")),
366    };
367    let mut report = VerifyReport {
368        name: Some(manifest.name.clone()),
369        version: Some(manifest.version.clone()),
370        structure: CheckOutcome::Pass,
371        content_hash: CheckOutcome::Skipped,
372        package_hash: CheckOutcome::Skipped,
373        signature: CheckOutcome::Skipped,
374        errors: Vec::new(),
375    };
376    if let Err(errs) = manifest.validate() {
377        report.structure = CheckOutcome::Fail;
378        report.errors.extend(errs);
379        return report;
380    }
381
382    // Kind ↔ payload coherence (GH #726) — a structural property: the
383    // declared kind must match the payload the document actually carries.
384    if let Ok(content) =
385        serde_json::from_value::<PackageContent>(value.get("content").cloned().unwrap_or_default())
386        && let Err(errs) = validate_kind_coherence(&manifest, &content)
387    {
388        report.structure = CheckOutcome::Fail;
389        report.errors.extend(errs);
390        return report;
391    }
392
393    // §8 — integrity against the writer's bytes.
394    let Some(content_text) = extract_top_level_value_text(doc, "content") else {
395        report.structure = CheckOutcome::Fail;
396        report
397            .errors
398            .push("could not locate the content member in the document".into());
399        return report;
400    };
401    let canonical = compact_json_text(content_text);
402    let actual_content_hash = sha256_hex(canonical.as_bytes());
403
404    if actual_content_hash == manifest.integrity.content_hash {
405        report.content_hash = CheckOutcome::Pass;
406    } else {
407        report.content_hash = CheckOutcome::Fail;
408        report.errors.push(format!(
409            "content_hash mismatch: manifest says {}, content hashes to {actual_content_hash}",
410            manifest.integrity.content_hash
411        ));
412    }
413    if manifest.integrity.byte_size != canonical.len() as u64 {
414        report.content_hash = CheckOutcome::Fail;
415        report.errors.push(format!(
416            "byte_size mismatch: manifest says {}, content is {} bytes",
417            manifest.integrity.byte_size,
418            canonical.len()
419        ));
420    }
421
422    let expected_sha = sha256_hex(
423        format!(
424            "{}:{}:{actual_content_hash}",
425            manifest.name, manifest.version
426        )
427        .as_bytes(),
428    );
429    if expected_sha == manifest.integrity.sha256 {
430        report.package_hash = CheckOutcome::Pass;
431    } else {
432        report.package_hash = CheckOutcome::Fail;
433        report.errors.push(format!(
434            "package sha256 mismatch: manifest says {}, recomputed {expected_sha}",
435            manifest.integrity.sha256
436        ));
437    }
438
439    // §9 — a present-but-invalid signature is always tampering.
440    if manifest.signature.is_some() {
441        match super::signing::verify_signature(&manifest) {
442            Ok(true) => report.signature = CheckOutcome::Pass,
443            Ok(false) => {
444                report.signature = CheckOutcome::Fail;
445                report.errors.push(
446                    "signature verification failed — the package was modified after signing".into(),
447                );
448            }
449            Err(e) => {
450                report.signature = CheckOutcome::Fail;
451                report.errors.push(format!("signature check errored: {e}"));
452            }
453        }
454    }
455
456    report
457}
458
459/// Read and verify a `.ctxpkg` file (size- and extension-gated like import).
460pub fn verify_package_file(path: &Path) -> Result<VerifyReport, String> {
461    if !crate::core::contracts::is_package_file(path) {
462        let ext = path
463            .extension()
464            .and_then(|e| e.to_str())
465            .unwrap_or("(none)");
466        return Err(format!(
467            "unsupported file extension '.{ext}' — expected .{} or .{}",
468            crate::core::contracts::PACKAGE_EXTENSION,
469            crate::core::contracts::LEGACY_PACKAGE_EXTENSION,
470        ));
471    }
472    let meta = std::fs::metadata(path).map_err(|e| format!("stat package file: {e}"))?;
473    if meta.len() > crate::core::contracts::MAX_PACKAGE_FILE_BYTES {
474        return Err(format!(
475            "package file too large ({} bytes, max {} bytes)",
476            meta.len(),
477            crate::core::contracts::MAX_PACKAGE_FILE_BYTES,
478        ));
479    }
480    let doc = std::fs::read_to_string(path).map_err(|e| format!("read package file: {e}"))?;
481    Ok(verify_package_text(&doc))
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use crate::core::context_package::content::PackageContent;
488    use crate::core::context_package::manifest::{
489        CompatibilitySpec, PackageIntegrity, PackageLayer, PackageProvenance, PackageStats,
490    };
491    use chrono::Utc;
492
493    fn signed_bundle_doc() -> String {
494        let content = PackageContent::default();
495        // Arbitrary content text: verification hashes the document bytes and
496        // never re-parses content into a typed struct.
497        let content_json = r#"{"note":"hello","weight":1.0}"#.to_string();
498        let content_hash = sha256_hex(content_json.as_bytes());
499        let sha = sha256_hex(format!("vt-pkg:1.0.0:{content_hash}").as_bytes());
500
501        let mut manifest = PackageManifest {
502            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
503            conformance_level: None,
504            kind: crate::core::context_package::manifest::PackageKind::default(),
505            name: "vt-pkg".into(),
506            version: "1.0.0".into(),
507            description: "verify test".into(),
508            author: None,
509            scope: None,
510            created_at: Utc::now(),
511            updated_at: None,
512            layers: vec![PackageLayer::Knowledge],
513            dependencies: vec![],
514            tags: vec![],
515            visibility: None,
516            integrity: PackageIntegrity {
517                sha256: sha,
518                content_hash,
519                byte_size: content_json.len() as u64,
520            },
521            provenance: PackageProvenance {
522                tool: "lean-ctx".into(),
523                tool_version: "0.0.0".into(),
524                project_hash: None,
525                source_session_id: None,
526            },
527            compatibility: CompatibilitySpec::default(),
528            stats: PackageStats::default(),
529            signature: None,
530            graph_summary: None,
531            marketplace: None,
532        };
533        let key = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]);
534        super::super::signing::sign_package(&mut manifest, &content, &key);
535
536        format!(
537            "{{\"manifest\":{},\"content\":{}}}",
538            serde_json::to_string(&manifest).unwrap(),
539            content_json
540        )
541    }
542
543    #[test]
544    fn valid_signed_package_passes_all_checks() {
545        let report = verify_package_text(&signed_bundle_doc());
546        assert!(report.valid(), "errors: {:?}", report.errors);
547        assert_eq!(report.structure, CheckOutcome::Pass);
548        assert_eq!(report.content_hash, CheckOutcome::Pass);
549        assert_eq!(report.package_hash, CheckOutcome::Pass);
550        assert_eq!(report.signature, CheckOutcome::Pass);
551    }
552
553    #[test]
554    fn unsigned_package_skips_signature() {
555        let doc = signed_bundle_doc();
556        let mut v: serde_json::Value = serde_json::from_str(&doc).unwrap();
557        v["manifest"]["signature"] = serde_json::Value::Null;
558        let report = verify_package_text(&serde_json::to_string(&v).unwrap());
559        assert_eq!(report.signature, CheckOutcome::Skipped);
560    }
561
562    #[test]
563    fn tampered_content_fails_content_hash() {
564        let doc = signed_bundle_doc().replace("\"hello\"", "\"evil\"");
565        let report = verify_package_text(&doc);
566        assert_eq!(report.content_hash, CheckOutcome::Fail);
567        assert!(!report.valid());
568    }
569
570    #[test]
571    fn whitespace_only_changes_do_not_break_hashing() {
572        // Pretty-printing the document moves bytes around the content member —
573        // compaction must recover the writer's exact value literals (incl. 1.0).
574        let doc = signed_bundle_doc()
575            .replace("\"content\":{", "\"content\": {\n  ")
576            .replace(",\"weight\"", ",\n  \"weight\"");
577        let report = verify_package_text(&doc);
578        assert!(report.valid(), "errors: {:?}", report.errors);
579    }
580
581    #[test]
582    fn corrupted_signature_fails() {
583        let doc = signed_bundle_doc();
584        let mut v: serde_json::Value = serde_json::from_str(&doc).unwrap();
585        let sig = v["manifest"]["signature"]["value"].as_str().unwrap();
586        let flipped = if let Some(rest) = sig.strip_prefix("0000") {
587            format!("ffff{rest}")
588        } else {
589            format!("0000{}", &sig[4..])
590        };
591        v["manifest"]["signature"]["value"] = flipped.into();
592        let report = verify_package_text(&serde_json::to_string(&v).unwrap());
593        assert_eq!(report.signature, CheckOutcome::Fail);
594    }
595
596    #[test]
597    fn missing_manifest_fails_structure() {
598        let report = verify_package_text("{\"content\":{}}");
599        assert_eq!(report.structure, CheckOutcome::Fail);
600        assert!(report.errors[0].contains("manifest"));
601    }
602
603    // --- kind ↔ payload coherence (GH #726) ---
604
605    const COHERENT_ADDON_TOML: &str = r#"
606[addon]
607name = "lean-md"
608version = "1.2.0"
609description = "Markdown skills runtime"
610
611[mcp]
612transport = "stdio"
613command = "lean-md"
614args = ["serve"]
615"#;
616
617    fn kinded_manifest(kind: super::PackageKind, name: &str, version: &str) -> PackageManifest {
618        PackageManifest {
619            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION,
620            conformance_level: None,
621            kind,
622            name: name.into(),
623            version: version.into(),
624            description: "coherence test".into(),
625            author: None,
626            scope: None,
627            created_at: Utc::now(),
628            updated_at: None,
629            layers: vec![],
630            dependencies: vec![],
631            tags: vec![],
632            visibility: None,
633            integrity: PackageIntegrity {
634                sha256: "a".repeat(64),
635                content_hash: "b".repeat(64),
636                byte_size: 1,
637            },
638            provenance: PackageProvenance {
639                tool: "lean-ctx".into(),
640                tool_version: "0.0.0".into(),
641                project_hash: None,
642                source_session_id: None,
643            },
644            compatibility: CompatibilitySpec::default(),
645            stats: PackageStats::default(),
646            signature: None,
647            graph_summary: None,
648            marketplace: None,
649        }
650    }
651
652    fn addon_content(toml: &str) -> PackageContent {
653        PackageContent {
654            addon: Some(crate::core::context_package::content::AddonContent {
655                manifest_toml: toml.to_string(),
656            }),
657            ..PackageContent::default()
658        }
659    }
660
661    #[test]
662    fn coherent_addon_pack_passes() {
663        let manifest = kinded_manifest(super::PackageKind::Addon, "@acme/lean-md", "1.2.0");
664        assert!(validate_kind_coherence(&manifest, &addon_content(COHERENT_ADDON_TOML)).is_ok());
665    }
666
667    #[test]
668    fn addon_kind_without_payload_fails() {
669        let manifest = kinded_manifest(super::PackageKind::Addon, "@acme/lean-md", "1.2.0");
670        let errs =
671            validate_kind_coherence(&manifest, &PackageContent::default()).expect_err("must fail");
672        assert!(errs[0].contains("requires a content.addon"), "{errs:?}");
673    }
674
675    #[test]
676    fn context_pack_with_addon_payload_fails() {
677        let manifest = kinded_manifest(super::PackageKind::Context, "plain-pack", "1.0.0");
678        let errs = validate_kind_coherence(&manifest, &addon_content(COHERENT_ADDON_TOML))
679            .expect_err("must fail");
680        assert!(errs[0].contains("requires kind=addon"), "{errs:?}");
681    }
682
683    #[test]
684    fn addon_name_and_version_must_match_the_pack() {
685        let manifest = kinded_manifest(super::PackageKind::Addon, "@acme/other-name", "9.9.9");
686        let errs = validate_kind_coherence(&manifest, &addon_content(COHERENT_ADDON_TOML))
687            .expect_err("must fail");
688        assert!(
689            errs.iter()
690                .any(|e| e.contains("does not match the package name")),
691            "{errs:?}"
692        );
693        assert!(
694            errs.iter()
695                .any(|e| e.contains("does not match the package version")),
696            "{errs:?}"
697        );
698    }
699}