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::manifest::PackageManifest;
12
13/// Strip insignificant whitespace outside string literals (spec §8).
14pub(crate) fn compact_json_text(text: &str) -> String {
15    let mut out = String::with_capacity(text.len());
16    let mut chars = text.chars();
17    let mut in_string = false;
18    while let Some(ch) = chars.next() {
19        if in_string {
20            out.push(ch);
21            match ch {
22                '\\' => {
23                    if let Some(esc) = chars.next() {
24                        out.push(esc);
25                    }
26                }
27                '"' => in_string = false,
28                _ => {}
29            }
30        } else {
31            match ch {
32                '"' => {
33                    in_string = true;
34                    out.push(ch);
35                }
36                ' ' | '\t' | '\n' | '\r' => {}
37                _ => out.push(ch),
38            }
39        }
40    }
41    out
42}
43
44/// Extract the exact text of one top-level member's value from a JSON object
45/// document, so integrity hashing sees the writer's bytes (spec §8).
46pub(crate) fn extract_top_level_value_text<'a>(doc: &'a str, member: &str) -> Option<&'a str> {
47    let bytes = doc.as_bytes();
48    let n = bytes.len();
49    let mut i = 0;
50
51    let skip_ws = |i: &mut usize| {
52        while *i < n && matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r') {
53            *i += 1;
54        }
55    };
56    let skip_string = |i: &mut usize| {
57        *i += 1; // opening quote
58        while *i < n {
59            match bytes[*i] {
60                b'\\' => *i += 2,
61                b'"' => {
62                    *i += 1;
63                    return;
64                }
65                _ => *i += 1,
66            }
67        }
68    };
69    let skip_value = |i: &mut usize| {
70        skip_ws(i);
71        match bytes.get(*i) {
72            Some(b'"') => skip_string(i),
73            Some(&open @ (b'{' | b'[')) => {
74                let close = if open == b'{' { b'}' } else { b']' };
75                let mut depth = 0usize;
76                while *i < n {
77                    match bytes[*i] {
78                        b'"' => {
79                            skip_string(i);
80                            continue;
81                        }
82                        c if c == open => depth += 1,
83                        c if c == close => {
84                            depth -= 1;
85                            if depth == 0 {
86                                *i += 1;
87                                return;
88                            }
89                        }
90                        _ => {}
91                    }
92                    *i += 1;
93                }
94            }
95            _ => {
96                while *i < n
97                    && !matches!(bytes[*i], b',' | b'}' | b']' | b' ' | b'\t' | b'\n' | b'\r')
98                {
99                    *i += 1;
100                }
101            }
102        }
103    };
104
105    skip_ws(&mut i);
106    if bytes.get(i) != Some(&b'{') {
107        return None;
108    }
109    i += 1;
110    loop {
111        skip_ws(&mut i);
112        match bytes.get(i) {
113            Some(b'"') => {}
114            _ => return None,
115        }
116        let key_start = i;
117        skip_string(&mut i);
118        let key: String = serde_json::from_str(&doc[key_start..i]).ok()?;
119        skip_ws(&mut i);
120        if bytes.get(i) != Some(&b':') {
121            return None;
122        }
123        i += 1;
124        skip_ws(&mut i);
125        if key == member {
126            let start = i;
127            skip_value(&mut i);
128            return Some(&doc[start..i]);
129        }
130        skip_value(&mut i);
131        skip_ws(&mut i);
132        if bytes.get(i) == Some(&b',') {
133            i += 1;
134        }
135    }
136}
137
138/// Outcome of one verification check.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum CheckOutcome {
141    Pass,
142    Fail,
143    /// Not applicable — e.g. signature check on an unsigned package.
144    Skipped,
145}
146
147impl CheckOutcome {
148    pub fn as_str(&self) -> &'static str {
149        match self {
150            Self::Pass => "pass",
151            Self::Fail => "fail",
152            Self::Skipped => "skipped",
153        }
154    }
155}
156
157/// Per-check verification report, mirroring the checks every conforming
158/// reader runs (and the shape of the @ctxpkg/verify reference output).
159#[derive(Debug)]
160pub struct VerifyReport {
161    pub name: Option<String>,
162    pub version: Option<String>,
163    pub structure: CheckOutcome,
164    pub content_hash: CheckOutcome,
165    pub package_hash: CheckOutcome,
166    pub signature: CheckOutcome,
167    pub errors: Vec<String>,
168}
169
170impl VerifyReport {
171    pub fn valid(&self) -> bool {
172        self.errors.is_empty()
173    }
174
175    fn failed(error: String) -> Self {
176        Self {
177            name: None,
178            version: None,
179            structure: CheckOutcome::Fail,
180            content_hash: CheckOutcome::Skipped,
181            package_hash: CheckOutcome::Skipped,
182            signature: CheckOutcome::Skipped,
183            errors: vec![error],
184        }
185    }
186}
187
188fn sha256_hex(data: &[u8]) -> String {
189    let mut h = Sha256::new();
190    h.update(data);
191    format!("{:x}", h.finalize())
192}
193
194/// Verify a `.ctxpkg` document without installing anything.
195pub fn verify_package_text(doc: &str) -> VerifyReport {
196    let value: serde_json::Value = match serde_json::from_str(doc) {
197        Ok(v) => v,
198        Err(e) => return VerifyReport::failed(format!("not valid JSON: {e}")),
199    };
200
201    let Some(manifest_value) = value.get("manifest") else {
202        return VerifyReport::failed("missing required member: manifest".into());
203    };
204    if value.get("content").is_none() {
205        return VerifyReport::failed("missing required member: content".into());
206    }
207
208    let manifest: PackageManifest = match serde_json::from_value(manifest_value.clone()) {
209        Ok(m) => m,
210        Err(e) => return VerifyReport::failed(format!("manifest does not parse: {e}")),
211    };
212    let mut report = VerifyReport {
213        name: Some(manifest.name.clone()),
214        version: Some(manifest.version.clone()),
215        structure: CheckOutcome::Pass,
216        content_hash: CheckOutcome::Skipped,
217        package_hash: CheckOutcome::Skipped,
218        signature: CheckOutcome::Skipped,
219        errors: Vec::new(),
220    };
221    if let Err(errs) = manifest.validate() {
222        report.structure = CheckOutcome::Fail;
223        report.errors.extend(errs);
224        return report;
225    }
226
227    // §8 — integrity against the writer's bytes.
228    let Some(content_text) = extract_top_level_value_text(doc, "content") else {
229        report.structure = CheckOutcome::Fail;
230        report
231            .errors
232            .push("could not locate the content member in the document".into());
233        return report;
234    };
235    let canonical = compact_json_text(content_text);
236    let actual_content_hash = sha256_hex(canonical.as_bytes());
237
238    if actual_content_hash == manifest.integrity.content_hash {
239        report.content_hash = CheckOutcome::Pass;
240    } else {
241        report.content_hash = CheckOutcome::Fail;
242        report.errors.push(format!(
243            "content_hash mismatch: manifest says {}, content hashes to {actual_content_hash}",
244            manifest.integrity.content_hash
245        ));
246    }
247    if manifest.integrity.byte_size != canonical.len() as u64 {
248        report.content_hash = CheckOutcome::Fail;
249        report.errors.push(format!(
250            "byte_size mismatch: manifest says {}, content is {} bytes",
251            manifest.integrity.byte_size,
252            canonical.len()
253        ));
254    }
255
256    let expected_sha = sha256_hex(
257        format!(
258            "{}:{}:{actual_content_hash}",
259            manifest.name, manifest.version
260        )
261        .as_bytes(),
262    );
263    if expected_sha == manifest.integrity.sha256 {
264        report.package_hash = CheckOutcome::Pass;
265    } else {
266        report.package_hash = CheckOutcome::Fail;
267        report.errors.push(format!(
268            "package sha256 mismatch: manifest says {}, recomputed {expected_sha}",
269            manifest.integrity.sha256
270        ));
271    }
272
273    // §9 — a present-but-invalid signature is always tampering.
274    if manifest.signature.is_some() {
275        match super::signing::verify_signature(&manifest) {
276            Ok(true) => report.signature = CheckOutcome::Pass,
277            Ok(false) => {
278                report.signature = CheckOutcome::Fail;
279                report.errors.push(
280                    "signature verification failed — the package was modified after signing".into(),
281                );
282            }
283            Err(e) => {
284                report.signature = CheckOutcome::Fail;
285                report.errors.push(format!("signature check errored: {e}"));
286            }
287        }
288    }
289
290    report
291}
292
293/// Read and verify a `.ctxpkg` file (size- and extension-gated like import).
294pub fn verify_package_file(path: &Path) -> Result<VerifyReport, String> {
295    if !crate::core::contracts::is_package_file(path) {
296        let ext = path
297            .extension()
298            .and_then(|e| e.to_str())
299            .unwrap_or("(none)");
300        return Err(format!(
301            "unsupported file extension '.{ext}' — expected .{} or .{}",
302            crate::core::contracts::PACKAGE_EXTENSION,
303            crate::core::contracts::LEGACY_PACKAGE_EXTENSION,
304        ));
305    }
306    let meta = std::fs::metadata(path).map_err(|e| format!("stat package file: {e}"))?;
307    if meta.len() > crate::core::contracts::MAX_PACKAGE_FILE_BYTES {
308        return Err(format!(
309            "package file too large ({} bytes, max {} bytes)",
310            meta.len(),
311            crate::core::contracts::MAX_PACKAGE_FILE_BYTES,
312        ));
313    }
314    let doc = std::fs::read_to_string(path).map_err(|e| format!("read package file: {e}"))?;
315    Ok(verify_package_text(&doc))
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::core::context_package::content::PackageContent;
322    use crate::core::context_package::manifest::{
323        CompatibilitySpec, PackageIntegrity, PackageLayer, PackageProvenance, PackageStats,
324    };
325    use chrono::Utc;
326
327    fn signed_bundle_doc() -> String {
328        let content = PackageContent::default();
329        // Arbitrary content text: verification hashes the document bytes and
330        // never re-parses content into a typed struct.
331        let content_json = r#"{"note":"hello","weight":1.0}"#.to_string();
332        let content_hash = sha256_hex(content_json.as_bytes());
333        let sha = sha256_hex(format!("vt-pkg:1.0.0:{content_hash}").as_bytes());
334
335        let mut manifest = PackageManifest {
336            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
337            conformance_level: None,
338            name: "vt-pkg".into(),
339            version: "1.0.0".into(),
340            description: "verify test".into(),
341            author: None,
342            scope: None,
343            created_at: Utc::now(),
344            updated_at: None,
345            layers: vec![PackageLayer::Knowledge],
346            dependencies: vec![],
347            tags: vec![],
348            visibility: None,
349            integrity: PackageIntegrity {
350                sha256: sha,
351                content_hash,
352                byte_size: content_json.len() as u64,
353            },
354            provenance: PackageProvenance {
355                tool: "lean-ctx".into(),
356                tool_version: "0.0.0".into(),
357                project_hash: None,
358                source_session_id: None,
359            },
360            compatibility: CompatibilitySpec::default(),
361            stats: PackageStats::default(),
362            signature: None,
363            graph_summary: None,
364            marketplace: None,
365        };
366        let key = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]);
367        super::super::signing::sign_package(&mut manifest, &content, &key);
368
369        format!(
370            "{{\"manifest\":{},\"content\":{}}}",
371            serde_json::to_string(&manifest).unwrap(),
372            content_json
373        )
374    }
375
376    #[test]
377    fn valid_signed_package_passes_all_checks() {
378        let report = verify_package_text(&signed_bundle_doc());
379        assert!(report.valid(), "errors: {:?}", report.errors);
380        assert_eq!(report.structure, CheckOutcome::Pass);
381        assert_eq!(report.content_hash, CheckOutcome::Pass);
382        assert_eq!(report.package_hash, CheckOutcome::Pass);
383        assert_eq!(report.signature, CheckOutcome::Pass);
384    }
385
386    #[test]
387    fn unsigned_package_skips_signature() {
388        let doc = signed_bundle_doc();
389        let mut v: serde_json::Value = serde_json::from_str(&doc).unwrap();
390        v["manifest"]["signature"] = serde_json::Value::Null;
391        let report = verify_package_text(&serde_json::to_string(&v).unwrap());
392        assert_eq!(report.signature, CheckOutcome::Skipped);
393    }
394
395    #[test]
396    fn tampered_content_fails_content_hash() {
397        let doc = signed_bundle_doc().replace("\"hello\"", "\"evil\"");
398        let report = verify_package_text(&doc);
399        assert_eq!(report.content_hash, CheckOutcome::Fail);
400        assert!(!report.valid());
401    }
402
403    #[test]
404    fn whitespace_only_changes_do_not_break_hashing() {
405        // Pretty-printing the document moves bytes around the content member —
406        // compaction must recover the writer's exact value literals (incl. 1.0).
407        let doc = signed_bundle_doc()
408            .replace("\"content\":{", "\"content\": {\n  ")
409            .replace(",\"weight\"", ",\n  \"weight\"");
410        let report = verify_package_text(&doc);
411        assert!(report.valid(), "errors: {:?}", report.errors);
412    }
413
414    #[test]
415    fn corrupted_signature_fails() {
416        let doc = signed_bundle_doc();
417        let mut v: serde_json::Value = serde_json::from_str(&doc).unwrap();
418        let sig = v["manifest"]["signature"]["value"].as_str().unwrap();
419        let flipped = if let Some(rest) = sig.strip_prefix("0000") {
420            format!("ffff{rest}")
421        } else {
422            format!("0000{}", &sig[4..])
423        };
424        v["manifest"]["signature"]["value"] = flipped.into();
425        let report = verify_package_text(&serde_json::to_string(&v).unwrap());
426        assert_eq!(report.signature, CheckOutcome::Fail);
427    }
428
429    #[test]
430    fn missing_manifest_fails_structure() {
431        let report = verify_package_text("{\"content\":{}}");
432        assert_eq!(report.structure, CheckOutcome::Fail);
433        assert!(report.errors[0].contains("manifest"));
434    }
435}