lean_ctx/core/context_package/
verify.rs1use sha2::{Digest, Sha256};
9use std::path::Path;
10
11use super::manifest::PackageManifest;
12
13pub(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
44pub(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; 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum CheckOutcome {
141 Pass,
142 Fail,
143 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#[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 crate::core::agent_identity::hex_encode(&h.finalize())
192}
193
194pub 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 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 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
293pub 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 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 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}