1use serde::{Deserialize, Serialize};
28use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum AppType {
36 Native,
38 Bun,
40 Standalone,
46 #[serde(rename = "platform-runtime")]
50 PlatformRuntime,
51 #[serde(rename = "managed-v1")]
54 ManagedV1,
55 Burger,
58}
59
60impl AppType {
61 pub fn as_str(self) -> &'static str {
62 match self {
63 AppType::Native => "native",
64 AppType::Bun => "bun",
65 AppType::Standalone => "standalone",
66 AppType::PlatformRuntime => "platform-runtime",
67 AppType::ManagedV1 => "managed-v1",
68 AppType::Burger => "burger",
69 }
70 }
71}
72
73impl std::fmt::Display for AppType {
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 write!(f, "{}", self.as_str())
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum AppTier {
86 FirstParty,
90 Optional,
94 Development,
105}
106
107impl AppTier {
108 pub fn as_str(self) -> &'static str {
109 match self {
110 AppTier::FirstParty => "first_party",
111 AppTier::Optional => "optional",
112 AppTier::Development => "development",
113 }
114 }
115}
116
117impl std::fmt::Display for AppTier {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 write!(f, "{}", self.as_str())
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "lowercase")]
129pub enum AbiVersion {
130 V1,
131}
132
133impl AbiVersion {
134 pub fn as_str(&self) -> &'static str {
135 match self {
136 AbiVersion::V1 => "v1",
137 }
138 }
139
140 pub fn is_supported(&self) -> bool {
142 matches!(self, AbiVersion::V1)
143 }
144}
145
146impl std::fmt::Display for AbiVersion {
147 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148 write!(f, "{}", self.as_str())
149 }
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "snake_case")]
158pub enum HotReloadKind {
159 Supported,
161 Experimental,
171 Unsupported,
173}
174
175impl HotReloadKind {
176 pub fn default_for(app_type: AppType) -> Self {
177 match app_type {
178 AppType::Native => HotReloadKind::Experimental,
179 AppType::Bun => HotReloadKind::Supported,
180 AppType::Standalone => HotReloadKind::Unsupported,
183 AppType::PlatformRuntime => HotReloadKind::Unsupported,
184 AppType::ManagedV1 => HotReloadKind::Supported,
185 AppType::Burger => HotReloadKind::Supported,
186 }
187 }
188}
189
190#[derive(Debug, Clone, Default, Serialize, Deserialize)]
197pub struct ManifestCapabilities {
198 #[serde(default)]
201 pub requires: Vec<String>,
202
203 #[serde(default)]
206 pub provides: Vec<String>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, Default)]
211pub struct ProvidedScope {
212 pub scope: String,
213 pub description: String,
214 pub resource_pattern: String,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct EndpointPolicy {
220 pub method: String,
221 pub path: String,
222 pub required_permissions: Vec<String>,
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct ProvidedCapability {
228 #[serde(default)]
229 pub description: String,
230 #[serde(default)]
231 pub schema: Option<serde_json::Value>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct StandaloneConfig {
246 pub socket_path: std::path::PathBuf,
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "snake_case")]
252pub enum AppUiKind {
253 Stage,
254 Widget,
255}
256
257fn default_app_ui_kind() -> AppUiKind {
258 AppUiKind::Stage
259}
260
261fn default_nav_section() -> String {
262 "default".to_string()
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267pub struct AppUiNav {
268 #[serde(default = "default_nav_section")]
269 pub section: String,
270 #[serde(default)]
271 pub order: i32,
272}
273
274pub const KNOWN_SURFACE_SLOTS: &[&str] = &["status-rail"];
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295pub struct AppUiSurface {
296 pub id: String,
297 pub slot: String,
298 pub entry: String,
299 pub title: String,
300 #[serde(default)]
301 pub order: i32,
302 #[serde(default)]
303 pub requires: AppUiRequirements,
304}
305
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
308#[serde(rename_all = "kebab-case")]
309pub enum AppDataOfflinePolicy {
310 LastKnown,
312 OnlineOnly,
314}
315
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
318#[serde(rename_all = "kebab-case")]
319pub enum AppDataQueryKind {
320 Collection,
321 Detail,
322 Snapshot,
323}
324
325#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
327#[serde(rename_all = "kebab-case")]
328pub enum AppDataStreamKind {
329 Changes,
330 Events,
331}
332
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(rename_all = "kebab-case")]
336pub enum AppDataSyncKind {
337 Cursor,
338 Snapshot,
339}
340
341#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct AppDataSyncPolicy {
344 pub kind: AppDataSyncKind,
345 pub cursor_ttl_secs: Option<u32>,
346 pub full_refresh_interval_secs: Option<u32>,
347 pub retention_secs: Option<u32>,
348}
349
350impl Serialize for AppDataSyncPolicy {
351 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
352 where
353 S: serde::Serializer,
354 {
355 use serde::ser::SerializeStruct;
356
357 if self.cursor_ttl_secs.is_none()
358 && self.full_refresh_interval_secs.is_none()
359 && self.retention_secs.is_none()
360 {
361 return self.kind.serialize(serializer);
362 }
363
364 let mut state = serializer.serialize_struct("AppDataSyncPolicy", 4)?;
365 state.serialize_field("kind", &self.kind)?;
366 if let Some(cursor_ttl_secs) = self.cursor_ttl_secs {
367 state.serialize_field("cursor_ttl_secs", &cursor_ttl_secs)?;
368 }
369 if let Some(full_refresh_interval_secs) = self.full_refresh_interval_secs {
370 state.serialize_field("full_refresh_interval_secs", &full_refresh_interval_secs)?;
371 }
372 if let Some(retention_secs) = self.retention_secs {
373 state.serialize_field("retention_secs", &retention_secs)?;
374 }
375 state.end()
376 }
377}
378
379impl<'de> Deserialize<'de> for AppDataSyncPolicy {
380 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
381 where
382 D: serde::Deserializer<'de>,
383 {
384 #[derive(Deserialize)]
385 #[serde(deny_unknown_fields)]
386 struct ObjectPolicy {
387 kind: AppDataSyncKind,
388 #[serde(default)]
389 cursor_ttl_secs: Option<u32>,
390 #[serde(default)]
391 full_refresh_interval_secs: Option<u32>,
392 #[serde(default)]
393 retention_secs: Option<u32>,
394 }
395
396 #[derive(Deserialize)]
397 #[serde(untagged)]
398 enum WirePolicy {
399 Kind(AppDataSyncKind),
400 Object(ObjectPolicy),
401 }
402
403 match WirePolicy::deserialize(deserializer)? {
404 WirePolicy::Kind(kind) => Ok(Self {
405 kind,
406 cursor_ttl_secs: None,
407 full_refresh_interval_secs: None,
408 retention_secs: None,
409 }),
410 WirePolicy::Object(policy) => Ok(Self {
411 kind: policy.kind,
412 cursor_ttl_secs: policy.cursor_ttl_secs,
413 full_refresh_interval_secs: policy.full_refresh_interval_secs,
414 retention_secs: policy.retention_secs,
415 }),
416 }
417 }
418}
419
420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
422#[serde(deny_unknown_fields)]
423pub struct AppDataQueryDeclaration {
424 pub name: String,
425 pub capability: String,
426 pub kind: AppDataQueryKind,
427}
428
429#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
431#[serde(deny_unknown_fields)]
432pub struct AppDataStreamDeclaration {
433 pub name: String,
434 pub kind: AppDataStreamKind,
435}
436
437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
439#[serde(deny_unknown_fields)]
440pub struct AppDataManifest {
441 pub namespace: String,
442 pub offline: AppDataOfflinePolicy,
443 pub sync: AppDataSyncPolicy,
444 #[serde(default)]
445 pub queries: Vec<AppDataQueryDeclaration>,
446 #[serde(default)]
447 pub streams: Vec<AppDataStreamDeclaration>,
448}
449
450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
455#[serde(deny_unknown_fields)]
456pub struct AppUiRequirements {
457 #[serde(default)]
458 pub capabilities: Vec<String>,
459 #[serde(default)]
460 pub queries: Vec<String>,
461 #[serde(default)]
462 pub streams: Vec<String>,
463}
464
465impl AppUiRequirements {
466 pub fn resolved(&self) -> Result<Vec<String>, String> {
470 let mut resolved = Vec::new();
471 let mut seen = HashSet::new();
472 for (values, allow_wildcard) in [
473 (&self.capabilities, true),
474 (&self.queries, false),
475 (&self.streams, false),
476 ] {
477 for value in values {
478 let requirement = value.trim();
479 if requirement.is_empty() {
480 return Err("ui.requires entries must not be blank".to_string());
481 }
482 validate_ui_requirement_name(requirement, allow_wildcard).map_err(|error| {
483 format!("ui.requires entry '{requirement}' invalid: {error}")
484 })?;
485 if seen.insert(requirement.to_string()) {
486 resolved.push(requirement.to_string());
487 }
488 }
489 }
490 Ok(resolved)
491 }
492}
493
494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
496pub struct AppUiManifest {
497 #[serde(default = "default_app_ui_kind")]
498 pub kind: AppUiKind,
499 pub entry: String,
500 pub title: String,
501 #[serde(default)]
502 pub icon: Option<String>,
503 #[serde(default)]
504 pub nav: Option<AppUiNav>,
505 #[serde(default)]
506 pub composes: Vec<String>,
507 #[serde(default)]
509 pub surfaces: Vec<AppUiSurface>,
510 pub ui_api: u8,
511 #[serde(default)]
512 pub integrity: BTreeMap<String, String>,
513 #[serde(default)]
517 pub description: Option<String>,
518 #[serde(default)]
521 pub keywords: Vec<String>,
522 #[serde(default)]
525 pub requires: AppUiRequirements,
526 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub data: Option<AppDataManifest>,
528}
529
530pub fn validate_manifest_path(path: &str) -> Result<(), String> {
542 if path.is_empty() {
543 return Err("path must not be empty".to_string());
544 }
545
546 if path.starts_with('/') {
548 return Err(format!(
549 "path '{}' must not be absolute (starts with /)",
550 path
551 ));
552 }
553
554 let first = path.chars().next().unwrap();
557 if !first.is_ascii_alphanumeric() && first != '_' {
558 return Err(format!(
559 "path '{}' must begin with an alphanumeric character or underscore",
560 path
561 ));
562 }
563 for ch in path.chars().skip(1) {
564 if !ch.is_ascii_alphanumeric() && !matches!(ch, '_' | '.' | '/' | '-') {
565 return Err(format!(
566 "path '{}' contains disallowed character '{}'",
567 path, ch
568 ));
569 }
570 }
571
572 for segment in path.split('/') {
574 if segment == ".." {
575 return Err(format!(
576 "path '{}' contains a '..' segment (path traversal rejected)",
577 path
578 ));
579 }
580 }
581
582 Ok(())
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct AppManifest {
594 #[serde(default = "default_manifest_version", rename = "manifest_version")]
596 pub manifest_version: u8,
597
598 pub name: String,
599 pub version: String,
600
601 #[serde(default = "default_app_type_native")]
602 pub app_type: AppType,
603
604 #[serde(default)]
605 pub description: String,
606
607 pub abi: Option<AbiVersion>,
610
611 pub entrypoint: Option<String>,
614
615 pub hot_reload: Option<HotReloadKind>,
618
619 #[serde(default)]
621 pub critical: bool,
622
623 #[serde(
624 default = "default_auto_start",
625 deserialize_with = "deserialize_auto_start"
626 )]
627 pub auto_start: bool,
628
629 #[serde(default)]
630 pub has_ui: bool,
631
632 #[serde(default = "default_ui_path")]
633 pub ui_path: String,
634
635 #[serde(default)]
636 pub permissions: Vec<String>,
637
638 #[serde(default)]
641 pub requires: Vec<String>,
642
643 #[serde(default)]
644 pub optional_permissions: Vec<String>,
645
646 #[serde(default)]
647 pub provides_scopes: Vec<ProvidedScope>,
648
649 #[serde(default)]
650 pub endpoint_policies: Vec<EndpointPolicy>,
651
652 #[serde(default)]
653 pub capability_scopes: HashMap<String, String>,
654
655 #[serde(default)]
656 pub provides: HashMap<String, ProvidedCapability>,
657
658 #[serde(default)]
660 pub capabilities: ManifestCapabilities,
661
662 #[serde(default, skip_serializing_if = "Option::is_none")]
665 pub ui: Option<AppUiManifest>,
666
667 #[serde(default)]
669 pub author: Option<String>,
670
671 #[serde(default)]
672 pub homepage: Option<String>,
673
674 #[serde(default)]
675 pub depends_on: Option<Vec<String>>,
676
677 #[serde(default)]
678 pub boot_priority: Option<u32>,
679
680 #[serde(default)]
684 pub governor: Option<GovernorManifest>,
685
686 #[serde(default)]
692 pub subscribes: Vec<String>,
693
694 #[serde(default)]
698 pub standalone: Option<StandaloneConfig>,
699
700 #[serde(default, skip_serializing_if = "Option::is_none")]
703 pub tcp: Option<TcpManifest>,
704
705 #[serde(default, skip_serializing_if = "Option::is_none")]
708 pub resources: Option<ResourcesManifest>,
709}
710
711pub const MAX_CALLBACK_DEADLINE_MS: u32 = 600_000;
713
714#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
720pub struct ResourcesManifest {
721 #[serde(default, skip_serializing_if = "Option::is_none")]
722 pub memory_mb: Option<u32>,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
724 pub callback_deadline_ms: Option<u32>,
725}
726
727#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
733#[serde(rename_all = "lowercase")]
734pub enum LatencyClass {
735 Interactive,
738 Background,
741}
742
743impl LatencyClass {
744 pub fn as_str(&self) -> &'static str {
745 match self {
746 LatencyClass::Interactive => "interactive",
747 LatencyClass::Background => "background",
748 }
749 }
750
751 #[allow(clippy::should_implement_trait)]
752 pub fn from_str(s: &str) -> Result<Self, String> {
753 match s {
754 "interactive" => Ok(LatencyClass::Interactive),
755 "background" => Ok(LatencyClass::Background),
756 _ => Err(format!("Invalid LatencyClass: {}", s)),
757 }
758 }
759}
760
761impl std::fmt::Display for LatencyClass {
762 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
763 write!(f, "{}", self.as_str())
764 }
765}
766
767#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
770pub struct GovernorManifest {
771 #[serde(default)]
775 pub terminable: Option<bool>,
776
777 #[serde(default)]
781 pub min_idle_secs: Option<u64>,
782
783 #[serde(default)]
816 pub memory_budget_kb: Option<u64>,
817
818 #[serde(default)]
823 pub latency_class: Option<LatencyClass>,
824}
825
826#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
830pub struct TcpManifest {
831 #[serde(default, skip_serializing_if = "Option::is_none")]
835 pub preferred_port: Option<u16>,
836
837 #[serde(default, skip_serializing_if = "Option::is_none")]
844 pub direct_bind: Option<bool>,
845}
846
847fn default_manifest_version() -> u8 {
848 1
849}
850
851fn default_auto_start() -> bool {
852 true
853}
854
855fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
861where
862 D: serde::Deserializer<'de>,
863{
864 #[derive(Deserialize)]
865 #[serde(untagged)]
866 enum BoolOrStr {
867 Bool(bool),
868 Str(String),
869 }
870 Ok(match BoolOrStr::deserialize(deserializer)? {
871 BoolOrStr::Bool(b) => b,
872 BoolOrStr::Str(s) => matches!(
873 s.trim().to_ascii_lowercase().as_str(),
874 "true" | "eager" | "active" | "auto" | "on" | "1"
875 ),
876 })
877}
878
879fn default_ui_path() -> String {
880 "dist".to_string()
881}
882
883fn default_app_type_native() -> AppType {
884 AppType::Native
885}
886
887impl AppManifest {
888 pub fn resolved_requires(&self) -> Result<Vec<String>, String> {
892 let top = normalized_requirements(&self.requires)?;
893 let nested = normalized_requirements(&self.capabilities.requires)?;
894 if !top.is_empty()
895 && !nested.is_empty()
896 && top.iter().cloned().collect::<BTreeSet<_>>()
897 != nested.iter().cloned().collect::<BTreeSet<_>>()
898 {
899 return Err("top-level 'requires' conflicts with 'capabilities.requires'".to_string());
900 }
901 Ok(if !top.is_empty() { top } else { nested })
902 }
903
904 pub fn effective_hot_reload(&self) -> HotReloadKind {
907 self.hot_reload
908 .unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
909 }
910
911 pub fn effective_entrypoint(&self) -> &str {
916 if let Some(ref ep) = self.entrypoint {
917 ep.as_str()
918 } else {
919 match self.app_type {
920 AppType::Native => "app.so",
921 AppType::Bun => "dist/index.js",
922 AppType::Standalone => "",
923 AppType::PlatformRuntime => "bun",
924 AppType::ManagedV1 => "llmc-generated-app",
925 AppType::Burger => "dist/index.js",
926 }
927 }
928 }
929
930 pub fn has_capability_providers(&self) -> bool {
933 !self.provides.is_empty() || !self.capabilities.provides.is_empty()
934 }
935
936 pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
950 let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
951 for raw in &self.capabilities.provides {
952 let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
953 if name.is_empty() {
954 continue;
955 }
956 out.entry(name).or_insert(ProvidedCapability {
957 description: String::new(),
958 schema: None,
959 });
960 }
961 out
962 }
963
964 pub fn validate(&self) -> Result<(), String> {
969 self.validate_with_socket_path_policy(false)
970 }
971
972 pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
977 if self.manifest_version == 2 && self.abi.is_none() {
979 return Err("manifest_version 2 requires an 'abi' field".to_string());
980 }
981
982 validate_app_name(&self.name)?;
985 self.resolved_requires()?;
986
987 if let Some(ref ep) = self.entrypoint {
989 validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
990 }
991 if !self.ui_path.is_empty() && self.ui_path != "dist" {
993 validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
994 }
995
996 if let Some(ui) = &self.ui {
997 validate_app_ui(&self.name, ui)?;
998 }
999
1000 if let Some(ref hp) = self.homepage {
1002 if !hp.starts_with("https://") && !hp.starts_with("http://") {
1003 return Err(format!(
1004 "homepage '{}' must use https:// or http:// scheme",
1005 hp
1006 ));
1007 }
1008 }
1009
1010 match self.app_type {
1017 AppType::Standalone => {
1018 if self.has_capability_providers() {
1019 let cfg = self.standalone.as_ref().ok_or_else(|| {
1020 "standalone apps that declare 'provides' require a \
1021 'standalone.socket_path' field"
1022 .to_string()
1023 })?;
1024 validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
1025 }
1026 }
1027 AppType::Native
1028 | AppType::Bun
1029 | AppType::PlatformRuntime
1030 | AppType::ManagedV1
1031 | AppType::Burger => {
1032 if self.standalone.is_some() {
1033 return Err(format!(
1034 "'standalone' block is only valid when app_type == 'standalone' \
1035 (found app_type='{}')",
1036 self.app_type
1037 ));
1038 }
1039 }
1040 }
1041
1042 if let Some(resources) = &self.resources {
1043 if resources.memory_mb == Some(0) {
1044 return Err("resources.memory_mb must be at least 1".to_string());
1045 }
1046 if let Some(deadline) = resources.callback_deadline_ms {
1047 if !(1..=MAX_CALLBACK_DEADLINE_MS).contains(&deadline) {
1048 return Err(format!(
1049 "resources.callback_deadline_ms must be between 1 and {MAX_CALLBACK_DEADLINE_MS} (found {deadline})"
1050 ));
1051 }
1052 }
1053 }
1054
1055 if self.app_type == AppType::PlatformRuntime
1056 && !self.resolved_capability_provides().is_empty()
1057 {
1058 return Err(
1059 "platform-runtime packages cannot provide runtime capabilities".to_string(),
1060 );
1061 }
1062
1063 Ok(())
1064 }
1065
1066 pub fn from_json(json: &str) -> Result<Self, String> {
1068 Self::from_json_with_socket_path_policy(json, false)
1069 }
1070
1071 pub fn from_json_with_socket_path_policy(
1073 json: &str,
1074 allow_non_run: bool,
1075 ) -> Result<Self, String> {
1076 let mut manifest: Self =
1077 serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
1078 if manifest.ui.is_some() {
1079 manifest.has_ui = true;
1080 }
1081 manifest.validate_with_socket_path_policy(allow_non_run)?;
1082 Ok(manifest)
1083 }
1084}
1085
1086fn normalized_requirements(values: &[String]) -> Result<Vec<String>, String> {
1087 let mut seen = HashSet::new();
1088 let mut resolved = Vec::new();
1089 for value in values {
1090 let requirement = value.trim();
1091 if requirement.is_empty() {
1092 return Err("capability requirements must not be blank".to_string());
1093 }
1094 if seen.insert(requirement.to_string()) {
1095 resolved.push(requirement.to_string());
1096 }
1097 }
1098 Ok(resolved)
1099}
1100
1101fn validate_app_ui(app_name: &str, ui: &AppUiManifest) -> Result<(), String> {
1102 if ui.ui_api != 1 && ui.ui_api != 2 {
1103 return Err(format!(
1104 "ui.ui_api {} is unsupported; only versions 1 and 2 are supported",
1105 ui.ui_api
1106 ));
1107 }
1108 if ui.title.trim().is_empty() {
1109 return Err("ui.title must not be blank".to_string());
1110 }
1111 ui.requires.resolved()?;
1112 validate_manifest_path(&ui.entry).map_err(|error| format!("ui.entry invalid: {error}"))?;
1113 if let Some(icon) = &ui.icon {
1114 validate_manifest_path(icon).map_err(|error| format!("ui.icon invalid: {error}"))?;
1115 }
1116 if let Some(nav) = &ui.nav {
1117 if ui.kind == AppUiKind::Widget {
1118 return Err("widget ui must omit nav metadata".to_string());
1119 }
1120 if nav.section.trim().is_empty() {
1121 return Err("ui.nav.section must not be blank".to_string());
1122 }
1123 }
1124 if let Some(data) = &ui.data {
1125 if ui.kind != AppUiKind::Stage {
1126 return Err("widget ui must omit app data declarations".to_string());
1127 }
1128 validate_app_data(data, &ui.requires.resolved()?)?;
1129 }
1130
1131 let mut composed = HashSet::new();
1132 for name in &ui.composes {
1133 validate_app_name(name).map_err(|error| format!("ui.composes entry invalid: {error}"))?;
1134 if name == app_name {
1135 return Err("ui.composes must not contain the app itself".to_string());
1136 }
1137 if !composed.insert(name) {
1138 return Err(format!("ui.composes contains duplicate app '{name}'"));
1139 }
1140 }
1141
1142 let mut surface_ids = HashSet::new();
1143 for surface in &ui.surfaces {
1144 let id = surface.id.trim();
1145 if id.is_empty() {
1146 return Err("ui.surfaces entry id must not be blank".to_string());
1147 }
1148 if !surface_ids.insert(id.to_string()) {
1149 return Err(format!("ui.surfaces contains duplicate id '{id}'"));
1150 }
1151 if !KNOWN_SURFACE_SLOTS.contains(&surface.slot.as_str()) {
1152 return Err(format!(
1153 "ui.surfaces entry '{id}' requests unknown slot '{}'; known slots: {}",
1154 surface.slot,
1155 KNOWN_SURFACE_SLOTS.join(", ")
1156 ));
1157 }
1158 if surface.title.trim().is_empty() {
1159 return Err(format!("ui.surfaces entry '{id}' title must not be blank"));
1160 }
1161 validate_manifest_path(&surface.entry)
1162 .map_err(|error| format!("ui.surfaces entry '{id}' entry invalid: {error}"))?;
1163 if !ui.integrity.contains_key(&surface.entry) {
1172 return Err(format!("ui.integrity must include surface '{id}' entry"));
1173 }
1174 surface
1175 .requires
1176 .resolved()
1177 .map_err(|error| format!("ui.surfaces entry '{id}' requires invalid: {error}"))?;
1178 }
1179
1180 for (path, digest) in &ui.integrity {
1181 validate_manifest_path(path)
1182 .map_err(|error| format!("ui.integrity path invalid: {error}"))?;
1183 if !is_lowercase_sha256(digest) {
1184 return Err(format!(
1185 "ui.integrity digest for '{path}' must be a lowercase 64-character SHA-256"
1186 ));
1187 }
1188 }
1189 if !ui.integrity.contains_key(&ui.entry) {
1190 return Err("ui.integrity must include the declared entry".to_string());
1191 }
1192 if let Some(icon) = &ui.icon {
1193 if !ui.integrity.contains_key(icon) {
1194 return Err("ui.integrity must include the declared icon".to_string());
1195 }
1196 }
1197 Ok(())
1198}
1199
1200fn validate_app_data(data: &AppDataManifest, resolved_requires: &[String]) -> Result<(), String> {
1201 validate_app_data_namespace(&data.namespace)?;
1202 validate_app_data_sync_policy(&data.sync)?;
1203 if data.queries.is_empty() {
1204 return Err("ui.data.queries must declare at least one query".to_string());
1205 }
1206
1207 let requires: BTreeSet<&str> = resolved_requires.iter().map(String::as_str).collect();
1208 let mut names = BTreeSet::new();
1209 for query in &data.queries {
1210 validate_namespaced_data_name(&query.name, &data.namespace)
1211 .map_err(|error| format!("ui.data query '{}' invalid: {error}", query.name))?;
1212 validate_capability_name(&query.capability).map_err(|error| {
1213 format!(
1214 "ui.data query '{}' capability '{}' invalid: {error}",
1215 query.name, query.capability
1216 )
1217 })?;
1218 if !requires.contains(query.capability.as_str()) {
1219 return Err(format!(
1220 "ui.data query '{}' capability '{}' must be declared in requires",
1221 query.name, query.capability
1222 ));
1223 }
1224 if !names.insert(query.name.as_str()) {
1225 return Err(format!("ui.data contains duplicate query '{}'", query.name));
1226 }
1227 }
1228
1229 for stream in &data.streams {
1230 validate_namespaced_data_name(&stream.name, &data.namespace)
1231 .map_err(|error| format!("ui.data stream '{}' invalid: {error}", stream.name))?;
1232 if !names.insert(stream.name.as_str()) {
1233 return Err(format!(
1234 "ui.data contains duplicate declaration '{}'",
1235 stream.name
1236 ));
1237 }
1238 }
1239
1240 Ok(())
1241}
1242
1243fn validate_app_data_namespace(namespace: &str) -> Result<(), String> {
1244 if !is_safe_name_segment(namespace) {
1245 return Err(format!(
1246 "ui.data namespace '{}' must match [a-z][a-z0-9-]*",
1247 namespace
1248 ));
1249 }
1250 if matches!(
1251 namespace,
1252 "core" | "internal" | "node" | "platform" | "system"
1253 ) {
1254 return Err(format!("ui.data namespace '{namespace}' is reserved"));
1255 }
1256 Ok(())
1257}
1258
1259fn validate_app_data_sync_policy(sync: &AppDataSyncPolicy) -> Result<(), String> {
1260 validate_optional_range("cursor_ttl_secs", sync.cursor_ttl_secs, 60, 86_400)?;
1261 validate_optional_range(
1262 "full_refresh_interval_secs",
1263 sync.full_refresh_interval_secs,
1264 60,
1265 604_800,
1266 )?;
1267 validate_optional_range("retention_secs", sync.retention_secs, 300, 31_536_000)?;
1268 if sync.kind == AppDataSyncKind::Snapshot && sync.cursor_ttl_secs.is_some() {
1269 return Err("ui.data.sync cursor_ttl_secs is only valid for cursor sync".to_string());
1270 }
1271 Ok(())
1272}
1273
1274fn validate_optional_range(
1275 field: &str,
1276 value: Option<u32>,
1277 min: u32,
1278 max: u32,
1279) -> Result<(), String> {
1280 if let Some(value) = value {
1281 if value < min || value > max {
1282 return Err(format!(
1283 "ui.data.sync {field} must be between {min} and {max} seconds"
1284 ));
1285 }
1286 }
1287 Ok(())
1288}
1289
1290fn validate_namespaced_data_name(name: &str, namespace: &str) -> Result<(), String> {
1291 validate_capability_name(name)?;
1292 let Some(rest) = name
1293 .strip_prefix(namespace)
1294 .and_then(|suffix| suffix.strip_prefix('.'))
1295 else {
1296 return Err(format!("name must use namespace '{namespace}'"));
1297 };
1298 if rest.is_empty() {
1299 return Err("name must include a value after its namespace".to_string());
1300 }
1301 if !has_version_suffix(name) {
1302 return Err("name must end with a .vN version suffix".to_string());
1303 }
1304 Ok(())
1305}
1306
1307fn validate_capability_name(name: &str) -> Result<(), String> {
1308 if name.is_empty() {
1309 return Err("name must not be empty".to_string());
1310 }
1311 if name.contains('/') || name.contains("..") {
1312 return Err("name must not contain path separators or traversal".to_string());
1313 }
1314 if !name.split('.').all(is_safe_declaration_segment) {
1315 return Err("name must contain only lowercase dot-separated segments".to_string());
1316 }
1317 Ok(())
1318}
1319
1320fn validate_ui_requirement_name(name: &str, allow_wildcard: bool) -> Result<(), String> {
1321 if allow_wildcard && name.ends_with(".*") {
1322 return validate_capability_name(&name[..name.len() - 2]);
1323 }
1324 validate_capability_name(name)
1325}
1326
1327fn has_version_suffix(name: &str) -> bool {
1328 let Some(version) = name.rsplit('.').next() else {
1329 return false;
1330 };
1331 let Some(digits) = version.strip_prefix('v') else {
1332 return false;
1333 };
1334 !digits.is_empty()
1335 && !digits.starts_with('0')
1336 && digits.bytes().all(|byte| byte.is_ascii_digit())
1337}
1338
1339fn is_safe_name_segment(segment: &str) -> bool {
1340 if segment.is_empty() {
1341 return false;
1342 }
1343 let mut chars = segment.chars();
1344 let Some(first) = chars.next() else {
1345 return false;
1346 };
1347 first.is_ascii_lowercase()
1348 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
1349}
1350
1351fn is_safe_declaration_segment(segment: &str) -> bool {
1364 let mut chars = segment.chars();
1365 let Some(first) = chars.next() else {
1366 return false;
1367 };
1368 first.is_ascii_lowercase()
1369 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
1370}
1371
1372fn is_lowercase_sha256(value: &str) -> bool {
1373 value.len() == 64
1374 && value
1375 .bytes()
1376 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1377}
1378
1379pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
1388 validate_standalone_socket_path_with_policy(path, false)
1389}
1390
1391pub fn validate_standalone_socket_path_with_policy(
1393 path: &std::path::Path,
1394 allow_non_run: bool,
1395) -> Result<(), String> {
1396 if !path.is_absolute() {
1397 return Err(format!(
1398 "standalone.socket_path '{}' must be absolute",
1399 path.display()
1400 ));
1401 }
1402 if !allow_non_run && !path.starts_with("/run/") {
1403 return Err(format!(
1404 "standalone.socket_path '{}' must live under /run/",
1405 path.display()
1406 ));
1407 }
1408 if path
1409 .components()
1410 .any(|c| matches!(c, std::path::Component::ParentDir))
1411 {
1412 return Err(format!(
1413 "standalone.socket_path '{}' must not contain '..' segments",
1414 path.display()
1415 ));
1416 }
1417 Ok(())
1418}
1419
1420fn validate_app_name(name: &str) -> Result<(), String> {
1424 let (publisher, app) = if let Some(slash) = name.find('/') {
1425 let (p, rest) = name.split_at(slash);
1426 (Some(p), &rest[1..])
1427 } else {
1428 (None, name)
1429 };
1430
1431 let valid_segment = |s: &str| -> bool {
1432 if s.is_empty() {
1433 return false;
1434 }
1435 let mut chars = s.chars();
1436 let first = chars.next().unwrap();
1437 if !first.is_ascii_lowercase() {
1438 return false;
1439 }
1440 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1441 };
1442
1443 if let Some(pub_name) = publisher {
1444 if !valid_segment(pub_name) {
1445 return Err(format!(
1446 "publisher segment '{}' must match [a-z][a-z0-9-]*",
1447 pub_name
1448 ));
1449 }
1450 }
1451
1452 if !valid_segment(app) {
1453 return Err(format!(
1454 "app name segment '{}' must match [a-z][a-z0-9-]*",
1455 app
1456 ));
1457 }
1458
1459 Ok(())
1460}
1461
1462#[cfg(test)]
1465mod tests {
1466 use super::*;
1467
1468 fn parse_ok(json: &str) -> AppManifest {
1469 AppManifest::from_json(json).expect("should parse")
1470 }
1471
1472 fn parse_err(json: &str) -> String {
1473 AppManifest::from_json(json).expect_err("should fail")
1474 }
1475
1476 #[test]
1479 fn v1_minimal_native() {
1480 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1481 assert_eq!(m.manifest_version, 1);
1482 assert_eq!(m.app_type, AppType::Native);
1483 assert!(m.abi.is_none());
1484 }
1485
1486 #[test]
1487 fn v1_minimal_bun() {
1488 let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
1489 assert_eq!(m.app_type, AppType::Bun);
1490 assert_eq!(m.effective_entrypoint(), "dist/index.js");
1491 }
1492
1493 #[test]
1494 fn v1_no_manifest_version_field_defaults_to_1() {
1495 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1496 assert_eq!(m.manifest_version, 1);
1497 }
1498
1499 #[test]
1500 fn v1_all_optional_fields_missing() {
1501 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1502 assert!(!m.critical);
1503 assert!(m.auto_start);
1504 assert!(!m.has_ui);
1505 assert_eq!(m.ui_path, "dist");
1506 assert!(m.permissions.is_empty());
1507 assert!(m.optional_permissions.is_empty());
1508 assert!(m.governor.is_none());
1511 assert!(m.subscribes.is_empty());
1512 }
1513
1514 #[test]
1515 fn v1_with_permissions_and_provides() {
1516 let json = r#"{
1517 "name": "example",
1518 "version": "1.0.0",
1519 "app_type": "bun",
1520 "permissions": ["core.storage.kv"],
1521 "optional_permissions": ["core.notifications.create"],
1522 "provides": {
1523 "core.example.run": { "description": "Run example job" }
1524 }
1525 }"#;
1526 let m = parse_ok(json);
1527 assert_eq!(m.permissions, vec!["core.storage.kv"]);
1528 assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
1529 assert!(m.provides.contains_key("core.example.run"));
1530 }
1531
1532 #[test]
1535 fn v2_minimal_native() {
1536 let json = r#"{
1537 "manifest_version": 2,
1538 "name": "cron",
1539 "version": "1.0.0",
1540 "app_type": "native",
1541 "abi": "v1",
1542 "entrypoint": "app.so",
1543 "hot_reload": "experimental"
1544 }"#;
1545 let m = parse_ok(json);
1546 assert_eq!(m.manifest_version, 2);
1547 assert_eq!(m.abi, Some(AbiVersion::V1));
1548 assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
1549 assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
1550 }
1551
1552 #[test]
1553 fn v2_minimal_bun_with_capabilities() {
1554 let json = r#"{
1555 "manifest_version": 2,
1556 "name": "example-fullstack",
1557 "version": "1.0.0",
1558 "app_type": "bun",
1559 "abi": "v1",
1560 "entrypoint": "dist/index.js",
1561 "hot_reload": "supported",
1562 "has_ui": true,
1563 "ui_path": "ui/dist",
1564 "capabilities": {
1565 "requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
1566 "provides": []
1567 },
1568 "governor": { "terminable": false, "min_idle_secs": 300 },
1569 "subscribes": ["core.chat.message.received"]
1570 }"#;
1571 let m = parse_ok(json);
1572 assert_eq!(m.manifest_version, 2);
1573 assert_eq!(m.capabilities.requires.len(), 2);
1574 let governor = m.governor.expect("governor block should parse");
1576 assert_eq!(governor.terminable, Some(false));
1577 assert_eq!(governor.min_idle_secs, Some(300));
1578 assert_eq!(m.subscribes, vec!["core.chat.message.received"]);
1579 }
1580
1581 #[test]
1582 fn stage_contract_fixture_parses_with_normalized_requirements() {
1583 let json = include_str!(
1584 "../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
1585 );
1586 let m = parse_ok(json);
1587 assert!(m.has_ui);
1588 assert_eq!(m.resolved_requires().unwrap(), vec!["core.metrics.latest"]);
1589 let ui = m.ui.expect("fixture should declare ui");
1590 assert_eq!(ui.kind, AppUiKind::Stage);
1591 assert_eq!(ui.entry, "ui/main.js");
1592 assert_eq!(ui.nav.unwrap().order, 10);
1593 }
1594
1595 #[test]
1596 fn omitted_ui_keeps_legacy_flags_without_fabricating_a_stage() {
1597 let m = parse_ok(
1598 r#"{"name":"legacy","version":"1.0.0","app_type":"bun","has_ui":true,"ui_path":"ui/dist"}"#,
1599 );
1600 assert!(m.has_ui);
1601 assert_eq!(m.ui_path, "ui/dist");
1602 assert!(m.ui.is_none());
1603 }
1604
1605 #[test]
1606 fn ui_requirements_are_typed_serialized_and_separate_from_backend_requires() {
1607 let manifest = parse_ok(
1608 r#"{
1609 "name":"ui-contract","version":"1.0.0","app_type":"bun",
1610 "requires":["core.cron.register"],
1611 "ui":{
1612 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
1613 "requires":{
1614 "capabilities":["ui.snapshot.v1"],
1615 "queries":["ui.query.v1"],
1616 "streams":["ui.event.v1"]
1617 },
1618 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
1619 }
1620 }"#,
1621 );
1622
1623 assert_eq!(
1624 manifest.resolved_requires().unwrap(),
1625 vec!["core.cron.register"]
1626 );
1627 let ui = manifest.ui.as_ref().expect("ui requirements should parse");
1628 assert_eq!(ui.requires.capabilities, vec!["ui.snapshot.v1"]);
1629 assert_eq!(ui.requires.queries, vec!["ui.query.v1"]);
1630 assert_eq!(ui.requires.streams, vec!["ui.event.v1"]);
1631 assert_eq!(
1632 ui.requires.resolved().unwrap(),
1633 vec!["ui.snapshot.v1", "ui.query.v1", "ui.event.v1"]
1634 );
1635 let serialized = serde_json::to_value(ui).unwrap();
1636 assert_eq!(
1637 serialized["requires"]["queries"],
1638 serde_json::json!(["ui.query.v1"])
1639 );
1640 }
1641
1642 #[test]
1643 fn ui_requirements_reject_blank_entries() {
1644 let error = parse_err(
1645 r#"{
1646 "name":"ui-contract","version":"1.0.0","app_type":"bun",
1647 "ui":{
1648 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
1649 "requires":{"capabilities":[""],"queries":[],"streams":[]},
1650 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
1651 }
1652 }"#,
1653 );
1654 assert!(error.contains("ui.requires entries must not be blank"));
1655 }
1656
1657 #[test]
1658 fn ui_data_namespace_stays_hyphen_only_when_declarations_allow_underscore() {
1659 assert!(validate_app_data_namespace("obs-viewer").is_ok());
1666
1667 let error = validate_app_data_namespace("obs_viewer")
1668 .expect_err("underscore must not be admitted into a storage namespace");
1669 assert!(
1670 error.contains("must match [a-z][a-z0-9-]*"),
1671 "unexpected error: {error}"
1672 );
1673 }
1674
1675 #[test]
1676 fn ui_data_query_capability_does_not_fall_back_to_backend_requires() {
1677 let error = parse_err(
1678 r#"{
1679 "name":"ui-data","version":"1.0.0","app_type":"bun",
1680 "requires":["ui.snapshot.v1"],
1681 "ui":{
1682 "kind":"stage","entry":"ui/main.js","title":"UI data","ui_api":1,
1683 "requires":{"capabilities":[],"queries":[],"streams":[]},
1684 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1685 "data":{
1686 "namespace":"ui-data","offline":"last-known","sync":{"kind":"cursor"},
1687 "queries":[{"name":"ui-data.snapshot.v1","capability":"ui.snapshot.v1","kind":"snapshot"}],
1688 "streams":[]
1689 }
1690 }
1691 }"#,
1692 );
1693 assert!(error.contains("must be declared in requires"));
1694 }
1695
1696 #[test]
1697 fn top_level_and_nested_requires_must_resolve_to_the_same_set() {
1698 let accepted = parse_ok(
1699 r#"{
1700 "name":"aliases","version":"1.0.0","app_type":"bun",
1701 "requires":["core.chat.read","core.chat.read","core.chat.send"],
1702 "capabilities":{"requires":["core.chat.send","core.chat.read"]}
1703 }"#,
1704 );
1705 assert_eq!(
1706 accepted.resolved_requires().unwrap(),
1707 vec!["core.chat.read", "core.chat.send"]
1708 );
1709
1710 let err = parse_err(
1711 r#"{
1712 "name":"aliases","version":"1.0.0","app_type":"bun",
1713 "requires":["core.chat.read"],
1714 "capabilities":{"requires":["core.wallet.pay"]}
1715 }"#,
1716 );
1717 assert!(err.contains("conflicts"), "unexpected error: {err}");
1718 }
1719
1720 #[test]
1721 fn stage_and_widget_ui_kinds_have_distinct_navigation_rules() {
1722 let base = |ui: &str| {
1723 format!(r#"{{"name":"stage","version":"1.0.0","app_type":"bun","ui":{ui}}}"#)
1724 };
1725 let widget = base(
1726 r#"{"kind":"widget","entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1727 );
1728 assert_eq!(
1729 parse_ok(&widget).ui.expect("widget ui").kind,
1730 AppUiKind::Widget
1731 );
1732 let widget_nav = base(
1733 r#"{"kind":"widget","entry":"ui/main.js","title":"Widget","nav":{"section":"default","order":1},"ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1734 );
1735 assert!(parse_err(&widget_nav).contains("must omit nav"));
1736 let api = base(
1737 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":2,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1738 );
1739 assert_eq!(parse_ok(&api).ui.expect("v2 stage ui").ui_api, 2);
1740 let unsupported_api = base(
1741 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":3,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1742 );
1743 assert!(parse_err(&unsupported_api).contains("ui_api"));
1744 let path = base(
1745 r#"{"kind":"stage","entry":"../main.js","title":"Stage","ui_api":1,"integrity":{"../main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1746 );
1747 assert!(parse_err(&path).contains("entry"));
1748 }
1749
1750 #[test]
1751 fn stage_requires_integrity_for_entry_and_icon() {
1752 let missing_entry = r#"{
1753 "name":"stage","version":"1.0.0","app_type":"bun",
1754 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{}}
1755 }"#;
1756 assert!(parse_err(missing_entry).contains("entry"));
1757 let missing_icon = r#"{
1758 "name":"stage","version":"1.0.0","app_type":"bun",
1759 "ui":{"entry":"ui/main.js","icon":"ui/icon.svg","title":"Stage","ui_api":1,
1760 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
1761 }"#;
1762 assert!(parse_err(missing_icon).contains("icon"));
1763 let uppercase = r#"{
1764 "name":"stage","version":"1.0.0","app_type":"bun",
1765 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
1766 "integrity":{"ui/main.js":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}
1767 }"#;
1768 assert!(parse_err(uppercase).contains("lowercase"));
1769 }
1770
1771 #[test]
1772 fn stage_composes_rejects_self_duplicate_and_unsafe_names() {
1773 let manifest = |composes: &str| {
1774 format!(
1775 r#"{{
1776 "name":"stage","version":"1.0.0","app_type":"bun",
1777 "ui":{{"entry":"ui/main.js","title":"Stage","ui_api":1,
1778 "composes":{composes},
1779 "integrity":{{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}}
1780 }}"#
1781 )
1782 };
1783 assert!(parse_err(&manifest(r#"["stage"]"#)).contains("itself"));
1784 assert!(parse_err(&manifest(r#"["chat","chat"]"#)).contains("duplicate"));
1785 assert!(parse_err(&manifest(r#"["../chat"]"#)).contains("invalid"));
1786 }
1787
1788 fn manifest_with_ui() -> AppManifest {
1800 parse_ok(
1801 r#"{
1802 "name":"stage","version":"1.0.0","app_type":"bun",
1803 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
1804 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1805 "ui/dist/surfaces/chip.js":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}
1806 }"#,
1807 )
1808 }
1809
1810 fn surface(id: &str, slot: &str) -> AppUiSurface {
1811 AppUiSurface {
1812 id: id.to_string(),
1813 slot: slot.to_string(),
1814 entry: "ui/dist/surfaces/chip.js".to_string(),
1815 title: "Chip".to_string(),
1816 order: 10,
1817 requires: AppUiRequirements {
1818 capabilities: vec!["wallet.balance.get".to_string()],
1819 ..Default::default()
1820 },
1821 }
1822 }
1823
1824 #[test]
1825 fn manifest_without_surfaces_still_parses() {
1826 let manifest = manifest_with_ui();
1827 assert!(manifest.ui.as_ref().unwrap().surfaces.is_empty());
1828 assert!(manifest.validate().is_ok());
1829 }
1830
1831 #[test]
1832 fn surface_in_a_known_slot_is_accepted() {
1833 let mut manifest = manifest_with_ui();
1834 manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "status-rail")];
1835 assert!(manifest.validate().is_ok());
1836 }
1837
1838 #[test]
1839 fn surface_in_an_unknown_slot_is_rejected() {
1840 let mut manifest = manifest_with_ui();
1841 manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "menu-bar")];
1842 let error = manifest.validate().unwrap_err();
1843 assert!(error.contains("menu-bar"), "unexpected error: {error}");
1844 }
1845
1846 #[test]
1847 fn duplicate_surface_ids_are_rejected() {
1848 let mut manifest = manifest_with_ui();
1849 manifest.ui.as_mut().unwrap().surfaces = vec![
1850 surface("chip", "status-rail"),
1851 surface("chip", "status-rail"),
1852 ];
1853 let error = manifest.validate().unwrap_err();
1854 assert!(error.contains("duplicate"), "unexpected error: {error}");
1855 }
1856
1857 #[test]
1858 fn surface_with_a_blank_id_is_rejected() {
1859 let mut manifest = manifest_with_ui();
1860 manifest.ui.as_mut().unwrap().surfaces = vec![surface(" ", "status-rail")];
1861 assert!(manifest.validate().is_err());
1862 }
1863
1864 #[test]
1865 fn surface_with_an_unsafe_entry_path_is_rejected() {
1866 let mut manifest = manifest_with_ui();
1867 let mut bad = surface("chip", "status-rail");
1868 bad.entry = "../../etc/passwd".to_string();
1869 manifest.ui.as_mut().unwrap().surfaces = vec![bad];
1870 assert!(manifest.validate().is_err());
1871 }
1872
1873 #[test]
1874 fn surface_entry_missing_from_integrity_is_rejected() {
1875 let mut manifest = manifest_with_ui();
1879 let mut unpinned = surface("chip", "status-rail");
1880 unpinned.entry = "ui/dist/surfaces/typo.js".to_string();
1881 manifest.ui.as_mut().unwrap().surfaces = vec![unpinned];
1882 let error = manifest.validate().unwrap_err();
1883 assert!(
1884 error.contains("ui.integrity must include surface 'chip' entry"),
1885 "unexpected error: {error}"
1886 );
1887 }
1888
1889 #[test]
1890 fn surface_with_a_blank_required_capability_is_rejected() {
1891 let mut manifest = manifest_with_ui();
1892 let mut bad = surface("chip", "status-rail");
1893 bad.requires.capabilities = vec![" ".to_string()];
1894 manifest.ui.as_mut().unwrap().surfaces = vec![bad];
1895 assert!(manifest.validate().is_err());
1896 }
1897
1898 #[test]
1899 fn v2_missing_abi_is_error() {
1900 let json = r#"{
1901 "manifest_version": 2,
1902 "name": "example",
1903 "version": "1.0.0",
1904 "app_type": "bun"
1905 }"#;
1906 let err = parse_err(json);
1907 assert!(err.contains("abi"), "expected abi error, got: {}", err);
1908 }
1909
1910 #[test]
1913 fn publisher_prefixed_name_accepted() {
1914 let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
1915 assert_eq!(m.name, "alice/weather");
1916 }
1917
1918 #[test]
1919 fn double_slash_name_rejected() {
1920 let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
1921 assert!(!err.is_empty());
1922 }
1923
1924 #[test]
1927 fn name_starting_with_digit_rejected() {
1928 let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
1929 assert!(!err.is_empty());
1930 }
1931
1932 #[test]
1933 fn name_with_uppercase_rejected() {
1934 let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
1935 assert!(!err.is_empty());
1936 }
1937
1938 #[test]
1939 fn empty_name_rejected() {
1940 let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
1941 assert!(!err.is_empty());
1942 }
1943
1944 #[test]
1947 fn path_traversal_double_dot_rejected() {
1948 let json = r#"{
1949 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1950 "app_type": "bun", "abi": "v1",
1951 "entrypoint": "../etc/passwd"
1952 }"#;
1953 let err = parse_err(json);
1954 assert!(err.contains(".."), "expected traversal error, got: {}", err);
1955 }
1956
1957 #[test]
1958 fn path_traversal_encoded_dot_not_decoded() {
1959 let json = r#"{
1961 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1962 "app_type": "bun", "abi": "v1",
1963 "entrypoint": "foo/../bar"
1964 }"#;
1965 let err = parse_err(json);
1966 assert!(!err.is_empty(), "should have failed: {}", err);
1967 }
1968
1969 #[test]
1970 fn absolute_path_rejected() {
1971 let json = r#"{
1972 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1973 "app_type": "bun", "abi": "v1",
1974 "entrypoint": "/usr/bin/sh"
1975 }"#;
1976 let err = parse_err(json);
1977 assert!(
1978 err.contains("absolute"),
1979 "expected absolute error, got: {}",
1980 err
1981 );
1982 }
1983
1984 #[test]
1985 fn shell_metachar_in_path_rejected() {
1986 let json = r#"{
1987 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1988 "app_type": "bun", "abi": "v1",
1989 "entrypoint": "dist/index.js;rm -rf /"
1990 }"#;
1991 let err = parse_err(json);
1992 assert!(!err.is_empty());
1993 }
1994
1995 #[test]
1996 fn valid_nested_path_accepted() {
1997 let json = r#"{
1998 "manifest_version": 2, "name": "my-app", "version": "1.0.0",
1999 "app_type": "bun", "abi": "v1",
2000 "entrypoint": "dist/index.js",
2001 "ui_path": "ui/dist"
2002 }"#;
2003 parse_ok(json);
2004 }
2005
2006 #[test]
2009 fn homepage_https_accepted() {
2010 let json = r#"{
2011 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2012 "homepage": "https://example.com"
2013 }"#;
2014 parse_ok(json);
2015 }
2016
2017 #[test]
2018 fn homepage_javascript_scheme_rejected() {
2019 let json = r#"{
2020 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2021 "homepage": "javascript:alert(1)"
2022 }"#;
2023 let err = parse_err(json);
2024 assert!(
2025 err.contains("scheme"),
2026 "expected scheme error, got: {}",
2027 err
2028 );
2029 }
2030
2031 #[test]
2032 fn homepage_file_scheme_rejected() {
2033 let json = r#"{
2034 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2035 "homepage": "file:///etc/passwd"
2036 }"#;
2037 let err = parse_err(json);
2038 assert!(!err.is_empty());
2039 }
2040
2041 #[test]
2044 fn effective_entrypoint_native_default() {
2045 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2046 assert_eq!(m.effective_entrypoint(), "app.so");
2047 }
2048
2049 #[test]
2050 fn effective_entrypoint_bun_default() {
2051 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2052 assert_eq!(m.effective_entrypoint(), "dist/index.js");
2053 }
2054
2055 #[test]
2056 fn effective_hot_reload_native_default_is_experimental() {
2057 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2058 assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
2059 }
2060
2061 #[test]
2062 fn effective_hot_reload_bun_default_is_supported() {
2063 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2064 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
2065 }
2066
2067 #[test]
2068 fn hot_reload_unsupported_explicit() {
2069 let json = r#"{
2070 "manifest_version": 2, "name": "myapp", "version": "1.0.0",
2071 "app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
2072 }"#;
2073 let m = parse_ok(json);
2074 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
2075 }
2076
2077 #[test]
2080 fn validate_path_simple_valid() {
2081 assert!(validate_manifest_path("dist/index.js").is_ok());
2082 assert!(validate_manifest_path("app.so").is_ok());
2083 assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
2084 assert!(validate_manifest_path("build_output/main").is_ok());
2085 }
2086
2087 #[test]
2088 fn validate_path_empty_rejected() {
2089 assert!(validate_manifest_path("").is_err());
2090 }
2091
2092 #[test]
2093 fn validate_path_absolute_rejected() {
2094 assert!(validate_manifest_path("/usr/bin/sh").is_err());
2095 }
2096
2097 #[test]
2098 fn validate_path_double_dot_segment_rejected() {
2099 assert!(validate_manifest_path("foo/../bar").is_err());
2100 assert!(validate_manifest_path("../etc/passwd").is_err());
2101 }
2102
2103 #[test]
2104 fn validate_path_leading_dot_rejected() {
2105 assert!(validate_manifest_path(".hidden").is_err());
2106 }
2107
2108 #[test]
2109 fn validate_path_null_byte_rejected() {
2110 let path = "foo\0bar";
2112 assert!(validate_manifest_path(path).is_err());
2113 }
2114
2115 #[test]
2116 fn standalone_socket_path_development_override_is_explicit_and_pure() {
2117 let path = std::path::Path::new("/tmp/node-app/example.sock");
2118 assert!(validate_standalone_socket_path(path).is_err());
2119 assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
2120 assert!(validate_standalone_socket_path_with_policy(
2121 std::path::Path::new("/tmp/node-app/../escape.sock"),
2122 true,
2123 )
2124 .is_err());
2125 }
2126
2127 #[test]
2130 fn manifest_capabilities_defaults_to_empty() {
2131 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2132 assert!(m.capabilities.requires.is_empty());
2133 assert!(m.capabilities.provides.is_empty());
2134 }
2135
2136 #[test]
2139 fn resolved_capability_provides_v1_only() {
2140 let json = r#"{
2141 "name": "example", "version": "1.0.0", "app_type": "bun",
2142 "provides": { "core.example.run": { "description": "Run example job" } }
2143 }"#;
2144 let m = parse_ok(json);
2145 let out = m.resolved_capability_provides();
2146 assert_eq!(out.len(), 1);
2147 assert_eq!(
2148 out.get("core.example.run").unwrap().description,
2149 "Run example job"
2150 );
2151 }
2152
2153 #[test]
2154 fn resolved_capability_provides_v2_names_get_blank_declaration() {
2155 let json = r#"{
2156 "manifest_version": 2, "name": "example", "version": "1.0.0",
2157 "app_type": "bun", "abi": "v1",
2158 "capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
2159 }"#;
2160 let m = parse_ok(json);
2161 let out = m.resolved_capability_provides();
2162 assert_eq!(out.len(), 2);
2163 assert_eq!(out.get("core.example.run").unwrap().description, "");
2164 assert!(out.get("core.example.run").unwrap().schema.is_none());
2165 assert!(out.contains_key("core.example.other"));
2167 assert!(!out.contains_key("core.example.other:extra"));
2168 }
2169
2170 #[test]
2171 fn resolved_capability_provides_v1_wins_on_conflict() {
2172 let json = r#"{
2173 "manifest_version": 2, "name": "example", "version": "1.0.0",
2174 "app_type": "bun", "abi": "v1",
2175 "provides": { "core.example.run": { "description": "v1 wins" } },
2176 "capabilities": { "requires": [], "provides": ["core.example.run"] }
2177 }"#;
2178 let m = parse_ok(json);
2179 let out = m.resolved_capability_provides();
2180 assert_eq!(out.len(), 1);
2181 assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
2182 }
2183
2184 #[test]
2185 fn resolved_capability_provides_blank_v2_name_skipped() {
2186 let json = r#"{
2187 "manifest_version": 2, "name": "example", "version": "1.0.0",
2188 "app_type": "bun", "abi": "v1",
2189 "capabilities": { "requires": [], "provides": [" ", "core.example.run"] }
2190 }"#;
2191 let m = parse_ok(json);
2192 let out = m.resolved_capability_provides();
2193 assert_eq!(out.len(), 1);
2194 assert!(out.contains_key("core.example.run"));
2195 }
2196
2197 #[test]
2198 fn resolved_capability_provides_empty_manifest_yields_empty_map() {
2199 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2200 assert!(m.resolved_capability_provides().is_empty());
2201 }
2202
2203 #[test]
2206 fn abi_v1_is_supported() {
2207 assert!(AbiVersion::V1.is_supported());
2208 }
2209
2210 #[test]
2213 fn app_tier_display() {
2214 assert_eq!(AppTier::FirstParty.to_string(), "first_party");
2215 assert_eq!(AppTier::Optional.to_string(), "optional");
2216 assert_eq!(AppTier::Development.to_string(), "development");
2217 }
2218
2219 #[test]
2220 fn app_tier_serde_roundtrip() {
2221 for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
2223 let json = serde_json::to_string(&tier).unwrap();
2224 let back: AppTier = serde_json::from_str(&json).unwrap();
2225 assert_eq!(
2226 tier, back,
2227 "roundtrip failed for {:?}: serialized as {}",
2228 tier, json
2229 );
2230 }
2231 assert_eq!(
2232 serde_json::to_string(&AppTier::Development).unwrap(),
2233 "\"development\""
2234 );
2235 }
2236
2237 #[test]
2240 fn app_type_display() {
2241 assert_eq!(AppType::Native.to_string(), "native");
2242 assert_eq!(AppType::Bun.to_string(), "bun");
2243 assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
2244 }
2245
2246 #[test]
2247 fn platform_runtime_is_a_supported_packaging_type() {
2248 let manifest: AppManifest = serde_json::from_value(serde_json::json!({
2249 "manifest_version": 2,
2250 "abi": "v1",
2251 "name": "bun-runtime",
2252 "version": "1.0.0",
2253 "app_type": "platform-runtime",
2254 "entrypoint": "bun"
2255 }))
2256 .expect("platform runtime manifest should parse");
2257
2258 assert_eq!(manifest.app_type, AppType::PlatformRuntime);
2259 assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
2260 }
2261
2262 #[test]
2265 fn governor_manifest_memory_budget_defaults_to_none() {
2266 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2267 assert_eq!(parsed.memory_budget_kb, None);
2268 }
2269
2270 #[test]
2271 fn governor_manifest_parses_declared_memory_budget() {
2272 let parsed: GovernorManifest =
2273 serde_json::from_str(r#"{"memory_budget_kb": 40960}"#).unwrap();
2274 assert_eq!(parsed.memory_budget_kb, Some(40_960));
2275 }
2276
2277 #[test]
2280 fn latency_class_parses_and_defaults_none() {
2281 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2282 assert_eq!(parsed.latency_class, None);
2283 }
2284
2285 #[test]
2286 fn latency_class_parses_declared_interactive() {
2287 let json = r#"{
2288 "name": "example",
2289 "version": "1.0.0",
2290 "app_type": "bun",
2291 "governor": {"latency_class": "interactive"}
2292 }"#;
2293 let m = parse_ok(json);
2294 let governor = m.governor.expect("governor block should parse");
2295 assert_eq!(governor.latency_class, Some(LatencyClass::Interactive));
2296 }
2297
2298 #[test]
2299 fn latency_class_parses_declared_background() {
2300 let parsed: GovernorManifest =
2301 serde_json::from_str(r#"{"latency_class": "background"}"#).unwrap();
2302 assert_eq!(parsed.latency_class, Some(LatencyClass::Background));
2303 }
2304
2305 #[test]
2306 fn latency_class_invalid_value_is_parse_error() {
2307 let result: Result<GovernorManifest, _> =
2308 serde_json::from_str(r#"{"latency_class": "urgent"}"#);
2309 assert!(result.is_err(), "unknown latency_class value must fail to parse");
2310 }
2311
2312 #[test]
2313 fn latency_class_as_str_and_from_str_roundtrip() {
2314 for class in [LatencyClass::Interactive, LatencyClass::Background] {
2315 let s = class.as_str();
2316 assert_eq!(LatencyClass::from_str(s), Ok(class));
2317 }
2318 assert!(LatencyClass::from_str("urgent").is_err());
2319 }
2320}
2321
2322#[cfg(test)]
2323mod shared_app_data_corpus_tests {
2324 use super::*;
2325
2326 #[test]
2331 fn shared_app_data_corpus_matches_the_host_validator() {
2332 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/app-data");
2333 let mut checked = 0;
2334 for entry in std::fs::read_dir(&dir).expect("fixture directory must exist") {
2335 let path = entry.expect("readable entry").path();
2336 if path.extension().and_then(|e| e.to_str()) != Some("json") {
2337 continue;
2338 }
2339 let fixture: serde_json::Value =
2340 serde_json::from_str(&std::fs::read_to_string(&path).expect("readable fixture"))
2341 .expect("valid fixture json");
2342 let name = path
2343 .file_name()
2344 .and_then(|n| n.to_str())
2345 .unwrap_or("?")
2346 .to_string();
2347 let manifest = serde_json::json!({
2348 "manifest_version": 2, "abi": "v1", "name": "fixture", "version": "0.1.0",
2349 "app_type": "bun", "entrypoint": "dist/index.js",
2350 "ui": fixture["ui"],
2351 });
2352 let result = AppManifest::from_json(&manifest.to_string()).and_then(|m| m.validate());
2353 match fixture["expect"].as_str().expect("expect field") {
2354 "accept" => assert!(result.is_ok(), "{name} should be accepted: {result:?}"),
2355 "reject" => {
2356 let error = result.expect_err(&format!("{name} should be rejected"));
2357 let reason = fixture["reason"]
2358 .as_str()
2359 .expect("reject fixtures need a reason");
2360 assert!(
2361 error.contains(reason),
2362 "{name}: {error:?} should mention {reason:?}"
2363 );
2364 }
2365 other => panic!("{name}: unknown expect {other:?}"),
2366 }
2367 checked += 1;
2368 }
2369 assert!(checked >= 10, "expected the full corpus, walked {checked}");
2371 }
2372}
2373
2374#[cfg(test)]
2375mod burger_manifest_tests {
2376 use super::*;
2377
2378 #[test]
2379 fn burger_app_type_parses_with_bundle_entrypoint_default() {
2380 let m = AppManifest::from_json(r#"{"name":"did","version":"2.0.0","app_type":"burger"}"#)
2381 .expect("burger manifest parses");
2382 assert_eq!(m.app_type, AppType::Burger);
2383 assert_eq!(m.app_type.as_str(), "burger");
2384 assert_eq!(m.effective_entrypoint(), "dist/index.js");
2385 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
2386 assert!(m.resources.is_none());
2387 }
2388
2389 #[test]
2390 fn resources_memory_mb_is_carried() {
2391 let m = AppManifest::from_json(
2392 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"memory_mb":48}}"#,
2393 )
2394 .expect("resources block parses");
2395 assert_eq!(
2396 m.resources,
2397 Some(ResourcesManifest { memory_mb: Some(48), callback_deadline_ms: None })
2398 );
2399 }
2400
2401 #[test]
2402 fn resources_callback_deadline_ms_is_carried() {
2403 let m = AppManifest::from_json(
2404 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"callback_deadline_ms":750}}"#,
2405 )
2406 .expect("callback deadline parses");
2407 assert_eq!(
2408 m.resources,
2409 Some(ResourcesManifest { memory_mb: None, callback_deadline_ms: Some(750) })
2410 );
2411 let max = AppManifest::from_json(
2412 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"callback_deadline_ms":600000}}"#,
2413 )
2414 .expect("the maximum is inclusive");
2415 assert_eq!(
2416 max.resources.and_then(|r| r.callback_deadline_ms),
2417 Some(MAX_CALLBACK_DEADLINE_MS)
2418 );
2419 }
2420
2421 #[test]
2422 fn resources_callback_deadline_ms_out_of_range_is_rejected() {
2423 for bad in ["0", "600001"] {
2424 let json = format!(
2425 r#"{{"name":"did","version":"2.0.0","app_type":"burger","resources":{{"callback_deadline_ms":{bad}}}}}"#
2426 );
2427 let err = AppManifest::from_json(&json).expect_err("out-of-range callback deadline");
2428 assert!(err.contains("resources.callback_deadline_ms"), "{bad}: {err}");
2429 }
2430 }
2431
2432 #[test]
2433 fn resources_callback_deadline_ms_must_be_a_positive_integer() {
2434 for bad in ["-1", "1.5", "\"5000\""] {
2435 let json = format!(
2436 r#"{{"name":"did","version":"2.0.0","app_type":"burger","resources":{{"callback_deadline_ms":{bad}}}}}"#
2437 );
2438 assert!(
2439 AppManifest::from_json(&json).is_err(),
2440 "callback_deadline_ms={bad} must be rejected"
2441 );
2442 }
2443 }
2444
2445 #[test]
2446 fn resources_memory_mb_zero_is_rejected() {
2447 let err = AppManifest::from_json(
2448 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"memory_mb":0}}"#,
2449 )
2450 .expect_err("a zero memory limit is invalid");
2451 assert!(err.contains("resources.memory_mb"), "{err}");
2452 }
2453
2454 #[test]
2455 fn burger_manifest_rejects_a_standalone_block() {
2456 let err = AppManifest::from_json(
2457 r#"{"name":"did","version":"2.0.0","app_type":"burger","standalone":{"socket_path":"/run/node-app-did.sock"}}"#,
2458 )
2459 .expect_err("standalone block is only valid for standalone apps");
2460 assert!(err.contains("only valid when app_type == 'standalone'"), "{err}");
2461 }
2462}