1use arkhe_forge_core::context::{ActionContext, ActionError};
20use arkhe_forge_core::event::{RuntimeBootstrap, SemVer};
21use arkhe_kernel::abi::{Tick, TypeCode};
22use serde::{Deserialize, Serialize};
23
24pub type ManifestDigest = [u8; 32];
26
27const DIGEST_DOMAIN: &str = "arkhe-forge-manifest-digest";
29
30#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct ShellSection {
36 pub shell_id: String,
38 pub display_name: String,
40}
41
42#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct RuntimeSection {
46 pub runtime_max: String,
49 pub runtime_current: String,
51}
52
53#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct AuditSection {
57 pub pii_cipher: String,
60 pub dek_backend: String,
63 pub kms_auto_promote: String,
67 pub signature_class: String,
71 pub compliance_tier: u8,
74}
75
76#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
79#[serde(default, deny_unknown_fields)]
80pub struct FrontendSection {
81 pub tls_required: bool,
83 pub alpha_credential_rotation_required: bool,
85}
86
87impl Default for FrontendSection {
88 fn default() -> Self {
89 Self {
90 tls_required: true,
91 alpha_credential_rotation_required: true,
92 }
93 }
94}
95
96#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct ManifestSnapshot {
100 pub schema_version: u16,
102 pub shell: ShellSection,
104 pub runtime: RuntimeSection,
106 pub audit: AuditSection,
108 #[serde(default)]
110 pub frontend: FrontendSection,
111}
112
113#[derive(Debug, thiserror::Error)]
117#[non_exhaustive]
118pub enum ManifestError {
119 #[error("parse error: {0}")]
121 ParseError(String),
122
123 #[error("missing required field: {0}")]
126 MissingRequired(&'static str),
127
128 #[error("tier {tier} incompatible with backend '{backend}'")]
130 TierBackendMismatch {
131 tier: u8,
133 backend: String,
135 },
136
137 #[error("software-kek rejected: runtime_current {current} > 0.15")]
140 SoftwareKekProductionRefused {
141 current: String,
143 },
144
145 #[error("version mismatch: runtime_max < runtime_current")]
149 VersionMismatch,
150
151 #[error("unknown field: {0}")]
154 UnknownField(String),
155
156 #[error("invalid value for {field}: {reason}")]
158 InvalidValue {
159 field: &'static str,
161 reason: String,
163 },
164}
165
166pub struct ManifestLoader;
170
171impl ManifestLoader {
172 pub fn load(toml_bytes: &[u8]) -> Result<(ManifestSnapshot, ManifestDigest), ManifestError> {
174 let text = core::str::from_utf8(toml_bytes)
175 .map_err(|e| ManifestError::ParseError(format!("utf-8: {}", e)))?;
176 let snapshot: ManifestSnapshot =
177 toml::from_str(text).map_err(|e| ManifestError::ParseError(e.to_string()))?;
178 Self::validate(&snapshot)?;
179 let digest = Self::canonical_digest(&snapshot)?;
180 Ok((snapshot, digest))
181 }
182
183 pub fn validate(m: &ManifestSnapshot) -> Result<(), ManifestError> {
187 if m.shell.shell_id.len() > 32 {
189 return Err(ManifestError::InvalidValue {
190 field: "shell.shell_id",
191 reason: format!("length {} > 32", m.shell.shell_id.len()),
192 });
193 }
194
195 let current =
197 parse_version(&m.runtime.runtime_current).ok_or(ManifestError::InvalidValue {
198 field: "runtime.runtime_current",
199 reason: format!(
200 "not a 'M.N' or 'M.N.P' semver: {}",
201 m.runtime.runtime_current
202 ),
203 })?;
204 let max = parse_version(&m.runtime.runtime_max).ok_or(ManifestError::InvalidValue {
205 field: "runtime.runtime_max",
206 reason: format!("not a 'M.N' or 'M.N.P' semver: {}", m.runtime.runtime_max),
207 })?;
208 if max < current {
209 return Err(ManifestError::VersionMismatch);
210 }
211
212 match (m.audit.compliance_tier, m.audit.dek_backend.as_str()) {
214 (0, "software-kek") => {}
215 (0, other) => {
216 return Err(ManifestError::TierBackendMismatch {
217 tier: 0,
218 backend: other.to_string(),
219 });
220 }
221 (1 | 2, "software-kek") => {
222 return Err(ManifestError::TierBackendMismatch {
223 tier: m.audit.compliance_tier,
224 backend: "software-kek".to_string(),
225 });
226 }
227 (1 | 2, _) => {}
228 _ => {
229 return Err(ManifestError::InvalidValue {
230 field: "audit.compliance_tier",
231 reason: format!("unsupported tier {}", m.audit.compliance_tier),
232 });
233 }
234 }
235
236 if m.audit.dek_backend == "software-kek" && (current.0, current.1) > (0, 15) {
238 return Err(ManifestError::SoftwareKekProductionRefused {
239 current: m.runtime.runtime_current.clone(),
240 });
241 }
242
243 Ok(())
244 }
245
246 pub fn canonical_digest(m: &ManifestSnapshot) -> Result<ManifestDigest, ManifestError> {
276 let canonical = toml::to_string(m)
277 .map_err(|e| ManifestError::ParseError(format!("toml serialize: {}", e)))?;
278 let key = blake3::derive_key(DIGEST_DOMAIN, &[]);
279 let hash = blake3::keyed_hash(&key, canonical.as_bytes());
280 let mut out = [0u8; 32];
281 out.copy_from_slice(hash.as_bytes());
282 Ok(out)
283 }
284}
285
286pub fn emit_runtime_bootstrap(
293 digest: &ManifestDigest,
294 l0_semver: SemVer,
295 runtime_semver: SemVer,
296 typecode_pins: Vec<TypeCode>,
297 bootstrap_tick: Tick,
298 ctx: &mut ActionContext<'_>,
299) -> Result<(), ActionError> {
300 let event = RuntimeBootstrap {
301 schema_version: 1,
302 l0_semver,
303 runtime_semver,
304 manifest_digest: *digest,
305 typecode_pins,
306 bootstrap_tick,
307 };
308 ctx.emit_event(&event)
309}
310
311fn parse_version(s: &str) -> Option<(u16, u16, u16)> {
317 let mut parts = s.splitn(3, '.');
318 let major: u16 = parts.next()?.parse().ok()?;
319 let minor: u16 = parts.next()?.parse().ok()?;
320 let patch: u16 = match parts.next() {
321 Some(p) => p.parse().ok()?,
322 None => 0,
323 };
324 Some((major, minor, patch))
325}
326
327#[cfg(test)]
330#[allow(clippy::unwrap_used, clippy::expect_used)]
331mod tests {
332 use super::*;
333 use arkhe_kernel::abi::{CapabilityMask, InstanceId, Principal};
334
335 pub(super) const TIER0_DEV_TOML: &str = r#"
336schema_version = 1
337
338[shell]
339shell_id = "shell.dev.example"
340display_name = "Dev Sandbox"
341
342[runtime]
343runtime_max = "0.15"
344runtime_current = "0.13"
345
346[audit]
347pii_cipher = "xchacha20-poly1305"
348dek_backend = "software-kek"
349kms_auto_promote = "manual"
350signature_class = "ed25519"
351compliance_tier = 0
352"#;
353
354 const TIER1_PROD_TOML: &str = r#"
355schema_version = 1
356
357[shell]
358shell_id = "shell.prod.example"
359display_name = "Prod Shell"
360
361[runtime]
362runtime_max = "0.20"
363runtime_current = "0.13"
364
365[audit]
366pii_cipher = "aes-256-gcm-siv"
367dek_backend = "aws-kms"
368kms_auto_promote = "after_60min"
369signature_class = "hybrid"
370compliance_tier = 1
371
372[frontend]
373tls_required = true
374alpha_credential_rotation_required = true
375"#;
376
377 #[test]
378 fn load_valid_tier0_dev_manifest() {
379 let (snap, digest) = ManifestLoader::load(TIER0_DEV_TOML.as_bytes()).unwrap();
380 assert_eq!(snap.schema_version, 1);
381 assert_eq!(snap.audit.compliance_tier, 0);
382 assert_eq!(snap.audit.dek_backend, "software-kek");
383 assert_eq!(digest.len(), 32);
384 assert!(snap.frontend.tls_required); }
386
387 #[test]
388 fn load_valid_tier1_prod_manifest() {
389 let (snap, digest) = ManifestLoader::load(TIER1_PROD_TOML.as_bytes()).unwrap();
390 assert_eq!(snap.audit.compliance_tier, 1);
391 assert_eq!(snap.audit.dek_backend, "aws-kms");
392 assert_eq!(digest.len(), 32);
393 }
394
395 #[test]
396 fn digest_is_deterministic_across_loads() {
397 let (_, d1) = ManifestLoader::load(TIER1_PROD_TOML.as_bytes()).unwrap();
398 let (_, d2) = ManifestLoader::load(TIER1_PROD_TOML.as_bytes()).unwrap();
399 assert_eq!(d1, d2);
400 }
401
402 #[test]
403 fn digest_is_whitespace_and_comment_invariant() {
404 let a = TIER0_DEV_TOML;
405 let b = r#"
407# comment block
408schema_version = 1
409
410[shell]
411shell_id = "shell.dev.example" # trailing comment
412display_name = "Dev Sandbox"
413
414[runtime]
415runtime_max = "0.15"
416runtime_current = "0.13"
417
418[audit]
419pii_cipher = "xchacha20-poly1305"
420dek_backend = "software-kek"
421kms_auto_promote = "manual"
422signature_class = "ed25519"
423compliance_tier = 0
424"#;
425 let (_, da) = ManifestLoader::load(a.as_bytes()).unwrap();
426 let (_, db) = ManifestLoader::load(b.as_bytes()).unwrap();
427 assert_eq!(da, db, "comments / whitespace must not affect digest");
428 }
429
430 #[test]
431 fn digest_differs_for_semantically_different_manifest() {
432 let (_, d0) = ManifestLoader::load(TIER0_DEV_TOML.as_bytes()).unwrap();
433 let (_, d1) = ManifestLoader::load(TIER1_PROD_TOML.as_bytes()).unwrap();
434 assert_ne!(d0, d1);
435 }
436
437 #[test]
438 fn print_tier0_dev_digest_for_pin() {
439 let (_, d) = ManifestLoader::load(TIER0_DEV_TOML.as_bytes()).unwrap();
444 eprintln!(
445 "tier0_dev digest = {:?}",
446 d.iter().map(|b| format!("0x{b:02x}")).collect::<Vec<_>>()
447 );
448 }
449
450 #[test]
451 fn tier0_with_kms_backend_rejected() {
452 let bad = TIER0_DEV_TOML.replace("software-kek", "aws-kms");
453 let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
454 assert!(matches!(
455 err,
456 ManifestError::TierBackendMismatch { tier: 0, .. }
457 ));
458 }
459
460 #[test]
461 fn tier1_with_software_kek_rejected() {
462 let bad = TIER1_PROD_TOML.replace("aws-kms", "software-kek");
463 let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
464 assert!(matches!(
465 err,
466 ManifestError::TierBackendMismatch { tier: 1, .. }
467 ));
468 }
469
470 #[test]
471 fn software_kek_past_0_15_refused() {
472 let bad = TIER0_DEV_TOML
473 .replace("runtime_current = \"0.13\"", "runtime_current = \"0.16\"")
474 .replace("runtime_max = \"0.15\"", "runtime_max = \"0.20\"");
476 let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
477 assert!(matches!(
478 err,
479 ManifestError::SoftwareKekProductionRefused { .. }
480 ));
481 }
482
483 #[test]
484 fn runtime_max_less_than_current_rejected() {
485 let bad = TIER0_DEV_TOML.replace("runtime_max = \"0.15\"", "runtime_max = \"0.5\"");
486 let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
487 assert!(matches!(err, ManifestError::VersionMismatch));
488 }
489
490 #[test]
491 fn parse_error_on_malformed_toml() {
492 let err = ManifestLoader::load(b"this is not toml == invalid").unwrap_err();
493 assert!(matches!(err, ManifestError::ParseError(_)));
494 }
495
496 #[test]
497 fn unknown_top_level_field_rejected() {
498 let bad = format!("{}\nunknown_field = 42\n", TIER0_DEV_TOML);
499 let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
500 assert!(matches!(err, ManifestError::ParseError(_)));
502 }
503
504 #[test]
505 fn bad_semver_rejected() {
506 let bad = TIER0_DEV_TOML.replace(
507 "runtime_current = \"0.13\"",
508 "runtime_current = \"not-a-version\"",
509 );
510 let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
511 assert!(matches!(
512 err,
513 ManifestError::InvalidValue {
514 field: "runtime.runtime_current",
515 ..
516 }
517 ));
518 }
519
520 #[test]
521 fn shell_id_over_32_bytes_rejected() {
522 let long_id = "a".repeat(33);
523 let bad = TIER0_DEV_TOML.replace("shell.dev.example", &long_id);
524 let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
525 assert!(matches!(
526 err,
527 ManifestError::InvalidValue {
528 field: "shell.shell_id",
529 ..
530 }
531 ));
532 }
533
534 #[test]
535 fn emit_runtime_bootstrap_appends_event() {
536 let (_, digest) = ManifestLoader::load(TIER0_DEV_TOML.as_bytes()).unwrap();
537 let mut ctx = ActionContext::new(
538 [0x11u8; 32],
539 InstanceId::new(1).unwrap(),
540 Tick(1),
541 Principal::System,
542 CapabilityMask::SYSTEM,
543 );
544 emit_runtime_bootstrap(
545 &digest,
546 SemVer::new(0, 11, 0),
547 SemVer::new(0, 11, 0),
548 vec![TypeCode(0x0003_0001), TypeCode(0x0003_0002)],
549 Tick(1),
550 &mut ctx,
551 )
552 .unwrap();
553 let events = ctx.drain_events();
554 assert_eq!(events.len(), 1);
555 assert_eq!(events[0].type_code, 0x0003_0F01);
556 assert_eq!(events[0].tick, Tick(1));
557
558 let bootstrap: RuntimeBootstrap = postcard::from_bytes(&events[0].payload).unwrap();
560 assert_eq!(bootstrap.manifest_digest, digest);
561 assert_eq!(bootstrap.l0_semver, SemVer::new(0, 11, 0));
562 }
563
564 #[test]
565 fn frontend_defaults_when_omitted() {
566 let (snap, _) = ManifestLoader::load(TIER0_DEV_TOML.as_bytes()).unwrap();
567 assert!(snap.frontend.tls_required);
569 assert!(snap.frontend.alpha_credential_rotation_required);
570 }
571
572 #[test]
573 fn parse_version_accepts_two_or_three_components() {
574 assert_eq!(parse_version("0.13"), Some((0, 13, 0)));
575 assert_eq!(parse_version("0.13.3"), Some((0, 13, 3)));
576 assert_eq!(parse_version(""), None);
577 assert_eq!(parse_version("0.13.3.4"), None); assert_eq!(parse_version("abc.def"), None);
579 }
580}
581
582#[cfg(test)]
591#[allow(clippy::unwrap_used, clippy::expect_used)]
592mod digest_invariant {
593 use super::tests::TIER0_DEV_TOML;
594 use super::ManifestLoader;
595
596 const TIER0_DEV_DIGEST_V0_11: [u8; 32] = [
600 0xa1, 0xbf, 0xe8, 0x2a, 0x57, 0xe1, 0xde, 0x55, 0x05, 0x7e, 0x47, 0x51, 0xd0, 0xeb, 0xed,
601 0x3d, 0x48, 0x82, 0xdb, 0xd5, 0x95, 0x2d, 0x83, 0xcd, 0x52, 0x10, 0x6c, 0x0f, 0xae, 0x3b,
602 0xab, 0x3c,
603 ];
604
605 #[test]
606 fn tier0_dev_digest_matches_v0_11_sentinel() {
607 let (_, digest) = ManifestLoader::load(TIER0_DEV_TOML.as_bytes())
608 .expect("TIER0_DEV_TOML must parse cleanly");
609 assert_eq!(
610 digest, TIER0_DEV_DIGEST_V0_11,
611 "manifest canonical_digest drifted from the pinned sentinel — check toml crate \
612 version, ManifestSnapshot field order, and DIGEST_DOMAIN. Update the sentinel + \
613 write a manifest schema micro-patch if the change is intentional."
614 );
615 }
616}