1use sha2::{Digest, Sha256};
9use std::path::Path;
10
11use super::content::PackageContent;
12use super::manifest::{PackageKind, PackageManifest};
13
14pub(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
45pub(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; 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
139pub 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 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
222fn 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
270pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub enum CheckOutcome {
296 Pass,
297 Fail,
298 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#[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
349pub 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 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 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 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
459pub 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 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 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 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}