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 #[serde(rename = "ui-only")]
65 UiOnly,
66}
67
68impl AppType {
69 pub fn as_str(self) -> &'static str {
70 match self {
71 AppType::Native => "native",
72 AppType::Bun => "bun",
73 AppType::Standalone => "standalone",
74 AppType::PlatformRuntime => "platform-runtime",
75 AppType::ManagedV1 => "managed-v1",
76 AppType::Burger => "burger",
77 AppType::UiOnly => "ui-only",
78 }
79 }
80}
81
82impl std::fmt::Display for AppType {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 write!(f, "{}", self.as_str())
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "snake_case")]
94pub enum AppTier {
95 FirstParty,
99 Optional,
103 Development,
114}
115
116impl AppTier {
117 pub fn as_str(self) -> &'static str {
118 match self {
119 AppTier::FirstParty => "first_party",
120 AppTier::Optional => "optional",
121 AppTier::Development => "development",
122 }
123 }
124}
125
126impl std::fmt::Display for AppTier {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 write!(f, "{}", self.as_str())
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "lowercase")]
138pub enum AbiVersion {
139 V1,
140}
141
142impl AbiVersion {
143 pub fn as_str(&self) -> &'static str {
144 match self {
145 AbiVersion::V1 => "v1",
146 }
147 }
148
149 pub fn is_supported(&self) -> bool {
151 matches!(self, AbiVersion::V1)
152 }
153}
154
155impl std::fmt::Display for AbiVersion {
156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 write!(f, "{}", self.as_str())
158 }
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum HotReloadKind {
168 Supported,
170 Experimental,
180 Unsupported,
182}
183
184impl HotReloadKind {
185 pub fn default_for(app_type: AppType) -> Self {
186 match app_type {
187 AppType::Native => HotReloadKind::Experimental,
188 AppType::Bun => HotReloadKind::Supported,
189 AppType::Standalone => HotReloadKind::Unsupported,
192 AppType::PlatformRuntime => HotReloadKind::Unsupported,
193 AppType::ManagedV1 => HotReloadKind::Supported,
194 AppType::Burger => HotReloadKind::Supported,
195 AppType::UiOnly => HotReloadKind::Unsupported,
197 }
198 }
199}
200
201#[derive(Debug, Clone, Default, Serialize, Deserialize)]
208pub struct ManifestCapabilities {
209 #[serde(default)]
212 pub requires: Vec<String>,
213
214 #[serde(default)]
217 pub provides: Vec<String>,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize, Default)]
222pub struct ProvidedScope {
223 pub scope: String,
224 pub description: String,
225 pub resource_pattern: String,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct EndpointPolicy {
231 pub method: String,
232 pub path: String,
233 pub required_permissions: Vec<String>,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct ProvidedCapability {
239 #[serde(default)]
240 pub description: String,
241 #[serde(default)]
242 pub schema: Option<serde_json::Value>,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct StandaloneConfig {
257 pub socket_path: std::path::PathBuf,
258}
259
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(rename_all = "snake_case")]
263pub enum AppUiKind {
264 Stage,
265 Widget,
266}
267
268fn default_app_ui_kind() -> AppUiKind {
269 AppUiKind::Stage
270}
271
272fn default_nav_section() -> String {
273 "default".to_string()
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278pub struct AppUiNav {
279 #[serde(default = "default_nav_section")]
280 pub section: String,
281 #[serde(default)]
282 pub order: i32,
283}
284
285pub const KNOWN_SURFACE_SLOTS: &[&str] = &["status-rail"];
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306pub struct AppUiSurface {
307 pub id: String,
308 pub slot: String,
309 pub entry: String,
310 pub title: String,
311 #[serde(default)]
312 pub order: i32,
313 #[serde(default)]
314 pub requires: AppUiRequirements,
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(rename_all = "kebab-case")]
320pub enum AppDataOfflinePolicy {
321 LastKnown,
323 OnlineOnly,
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(rename_all = "kebab-case")]
330pub enum AppDataQueryKind {
331 Collection,
332 Detail,
333 Snapshot,
334}
335
336#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
338#[serde(rename_all = "kebab-case")]
339pub enum AppDataStreamKind {
340 Changes,
341 Events,
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(rename_all = "kebab-case")]
347pub enum AppDataSyncKind {
348 Cursor,
349 Snapshot,
350}
351
352#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct AppDataSyncPolicy {
355 pub kind: AppDataSyncKind,
356 pub cursor_ttl_secs: Option<u32>,
357 pub full_refresh_interval_secs: Option<u32>,
358 pub retention_secs: Option<u32>,
359}
360
361impl Serialize for AppDataSyncPolicy {
362 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
363 where
364 S: serde::Serializer,
365 {
366 use serde::ser::SerializeStruct;
367
368 if self.cursor_ttl_secs.is_none()
369 && self.full_refresh_interval_secs.is_none()
370 && self.retention_secs.is_none()
371 {
372 return self.kind.serialize(serializer);
373 }
374
375 let mut state = serializer.serialize_struct("AppDataSyncPolicy", 4)?;
376 state.serialize_field("kind", &self.kind)?;
377 if let Some(cursor_ttl_secs) = self.cursor_ttl_secs {
378 state.serialize_field("cursor_ttl_secs", &cursor_ttl_secs)?;
379 }
380 if let Some(full_refresh_interval_secs) = self.full_refresh_interval_secs {
381 state.serialize_field("full_refresh_interval_secs", &full_refresh_interval_secs)?;
382 }
383 if let Some(retention_secs) = self.retention_secs {
384 state.serialize_field("retention_secs", &retention_secs)?;
385 }
386 state.end()
387 }
388}
389
390impl<'de> Deserialize<'de> for AppDataSyncPolicy {
391 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
392 where
393 D: serde::Deserializer<'de>,
394 {
395 #[derive(Deserialize)]
396 #[serde(deny_unknown_fields)]
397 struct ObjectPolicy {
398 kind: AppDataSyncKind,
399 #[serde(default)]
400 cursor_ttl_secs: Option<u32>,
401 #[serde(default)]
402 full_refresh_interval_secs: Option<u32>,
403 #[serde(default)]
404 retention_secs: Option<u32>,
405 }
406
407 #[derive(Deserialize)]
408 #[serde(untagged)]
409 enum WirePolicy {
410 Kind(AppDataSyncKind),
411 Object(ObjectPolicy),
412 }
413
414 match WirePolicy::deserialize(deserializer)? {
415 WirePolicy::Kind(kind) => Ok(Self {
416 kind,
417 cursor_ttl_secs: None,
418 full_refresh_interval_secs: None,
419 retention_secs: None,
420 }),
421 WirePolicy::Object(policy) => Ok(Self {
422 kind: policy.kind,
423 cursor_ttl_secs: policy.cursor_ttl_secs,
424 full_refresh_interval_secs: policy.full_refresh_interval_secs,
425 retention_secs: policy.retention_secs,
426 }),
427 }
428 }
429}
430
431#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
433#[serde(deny_unknown_fields)]
434pub struct AppDataQueryDeclaration {
435 pub name: String,
436 pub capability: String,
437 pub kind: AppDataQueryKind,
438}
439
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
442#[serde(deny_unknown_fields)]
443pub struct AppDataStreamDeclaration {
444 pub name: String,
445 pub kind: AppDataStreamKind,
446}
447
448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
450#[serde(deny_unknown_fields)]
451pub struct AppDataManifest {
452 pub namespace: String,
453 pub offline: AppDataOfflinePolicy,
454 pub sync: AppDataSyncPolicy,
455 #[serde(default)]
456 pub queries: Vec<AppDataQueryDeclaration>,
457 #[serde(default)]
458 pub streams: Vec<AppDataStreamDeclaration>,
459}
460
461#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
466#[serde(deny_unknown_fields)]
467pub struct AppUiRequirements {
468 #[serde(default)]
469 pub capabilities: Vec<String>,
470 #[serde(default)]
471 pub queries: Vec<String>,
472 #[serde(default)]
473 pub streams: Vec<String>,
474}
475
476impl AppUiRequirements {
477 pub fn resolved(&self) -> Result<Vec<String>, String> {
481 let mut resolved = Vec::new();
482 let mut seen = HashSet::new();
483 for (values, allow_wildcard) in [
484 (&self.capabilities, true),
485 (&self.queries, false),
486 (&self.streams, false),
487 ] {
488 for value in values {
489 let requirement = value.trim();
490 if requirement.is_empty() {
491 return Err("ui.requires entries must not be blank".to_string());
492 }
493 validate_ui_requirement_name(requirement, allow_wildcard).map_err(|error| {
494 format!("ui.requires entry '{requirement}' invalid: {error}")
495 })?;
496 if seen.insert(requirement.to_string()) {
497 resolved.push(requirement.to_string());
498 }
499 }
500 }
501 Ok(resolved)
502 }
503}
504
505#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
507pub struct AppUiManifest {
508 #[serde(default = "default_app_ui_kind")]
509 pub kind: AppUiKind,
510 pub entry: String,
511 pub title: String,
512 #[serde(default)]
513 pub icon: Option<String>,
514 #[serde(default)]
515 pub nav: Option<AppUiNav>,
516 #[serde(default)]
517 pub composes: Vec<String>,
518 #[serde(default)]
520 pub surfaces: Vec<AppUiSurface>,
521 pub ui_api: u8,
522 #[serde(default)]
523 pub integrity: BTreeMap<String, String>,
524 #[serde(default)]
528 pub description: Option<String>,
529 #[serde(default)]
532 pub keywords: Vec<String>,
533 #[serde(default)]
536 pub requires: AppUiRequirements,
537 #[serde(default, skip_serializing_if = "Option::is_none")]
538 pub data: Option<AppDataManifest>,
539}
540
541pub fn validate_manifest_path(path: &str) -> Result<(), String> {
553 if path.is_empty() {
554 return Err("path must not be empty".to_string());
555 }
556
557 if path.starts_with('/') {
559 return Err(format!(
560 "path '{}' must not be absolute (starts with /)",
561 path
562 ));
563 }
564
565 let first = path.chars().next().unwrap();
568 if !first.is_ascii_alphanumeric() && first != '_' {
569 return Err(format!(
570 "path '{}' must begin with an alphanumeric character or underscore",
571 path
572 ));
573 }
574 for ch in path.chars().skip(1) {
575 if !ch.is_ascii_alphanumeric() && !matches!(ch, '_' | '.' | '/' | '-') {
576 return Err(format!(
577 "path '{}' contains disallowed character '{}'",
578 path, ch
579 ));
580 }
581 }
582
583 for segment in path.split('/') {
585 if segment == ".." {
586 return Err(format!(
587 "path '{}' contains a '..' segment (path traversal rejected)",
588 path
589 ));
590 }
591 }
592
593 Ok(())
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize)]
604pub struct AppManifest {
605 #[serde(default = "default_manifest_version", rename = "manifest_version")]
607 pub manifest_version: u8,
608
609 pub name: String,
610 pub version: String,
611
612 #[serde(default = "default_app_type_native")]
613 pub app_type: AppType,
614
615 #[serde(default)]
616 pub description: String,
617
618 pub abi: Option<AbiVersion>,
621
622 pub entrypoint: Option<String>,
625
626 pub hot_reload: Option<HotReloadKind>,
629
630 #[serde(default)]
632 pub critical: bool,
633
634 #[serde(
635 default = "default_auto_start",
636 deserialize_with = "deserialize_auto_start"
637 )]
638 pub auto_start: bool,
639
640 #[serde(default)]
641 pub has_ui: bool,
642
643 #[serde(default = "default_ui_path")]
644 pub ui_path: String,
645
646 #[serde(default)]
647 pub permissions: Vec<String>,
648
649 #[serde(default)]
652 pub requires: Vec<String>,
653
654 #[serde(default)]
655 pub optional_permissions: Vec<String>,
656
657 #[serde(default)]
658 pub provides_scopes: Vec<ProvidedScope>,
659
660 #[serde(default)]
661 pub endpoint_policies: Vec<EndpointPolicy>,
662
663 #[serde(default)]
664 pub capability_scopes: HashMap<String, String>,
665
666 #[serde(default)]
667 pub provides: HashMap<String, ProvidedCapability>,
668
669 #[serde(default)]
671 pub capabilities: ManifestCapabilities,
672
673 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub ui: Option<AppUiManifest>,
677
678 #[serde(default)]
680 pub author: Option<String>,
681
682 #[serde(default)]
683 pub homepage: Option<String>,
684
685 #[serde(default)]
686 pub depends_on: Option<Vec<String>>,
687
688 #[serde(default)]
689 pub boot_priority: Option<u32>,
690
691 #[serde(default)]
695 pub governor: Option<GovernorManifest>,
696
697 #[serde(default)]
703 pub subscribes: Vec<String>,
704
705 #[serde(default)]
709 pub standalone: Option<StandaloneConfig>,
710
711 #[serde(default, skip_serializing_if = "Option::is_none")]
714 pub tcp: Option<TcpManifest>,
715
716 #[serde(default, skip_serializing_if = "Option::is_none")]
719 pub resources: Option<ResourcesManifest>,
720}
721
722pub const MAX_CALLBACK_DEADLINE_MS: u32 = 600_000;
724
725#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
731pub struct ResourcesManifest {
732 #[serde(default, skip_serializing_if = "Option::is_none")]
733 pub memory_mb: Option<u32>,
734 #[serde(default, skip_serializing_if = "Option::is_none")]
735 pub callback_deadline_ms: Option<u32>,
736}
737
738#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
744#[serde(rename_all = "lowercase")]
745pub enum LatencyClass {
746 Interactive,
749 Background,
752}
753
754impl LatencyClass {
755 pub fn as_str(&self) -> &'static str {
756 match self {
757 LatencyClass::Interactive => "interactive",
758 LatencyClass::Background => "background",
759 }
760 }
761
762 #[allow(clippy::should_implement_trait)]
763 pub fn from_str(s: &str) -> Result<Self, String> {
764 match s {
765 "interactive" => Ok(LatencyClass::Interactive),
766 "background" => Ok(LatencyClass::Background),
767 _ => Err(format!("Invalid LatencyClass: {}", s)),
768 }
769 }
770}
771
772impl std::fmt::Display for LatencyClass {
773 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
774 write!(f, "{}", self.as_str())
775 }
776}
777
778#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
781pub struct GovernorManifest {
782 #[serde(default)]
786 pub terminable: Option<bool>,
787
788 #[serde(default)]
792 pub min_idle_secs: Option<u64>,
793
794 #[serde(default)]
827 pub memory_budget_kb: Option<u64>,
828
829 #[serde(default)]
834 pub latency_class: Option<LatencyClass>,
835}
836
837#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
841pub struct TcpManifest {
842 #[serde(default, skip_serializing_if = "Option::is_none")]
846 pub preferred_port: Option<u16>,
847
848 #[serde(default, skip_serializing_if = "Option::is_none")]
855 pub direct_bind: Option<bool>,
856}
857
858fn default_manifest_version() -> u8 {
859 1
860}
861
862fn default_auto_start() -> bool {
863 true
864}
865
866fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
872where
873 D: serde::Deserializer<'de>,
874{
875 #[derive(Deserialize)]
876 #[serde(untagged)]
877 enum BoolOrStr {
878 Bool(bool),
879 Str(String),
880 }
881 Ok(match BoolOrStr::deserialize(deserializer)? {
882 BoolOrStr::Bool(b) => b,
883 BoolOrStr::Str(s) => matches!(
884 s.trim().to_ascii_lowercase().as_str(),
885 "true" | "eager" | "active" | "auto" | "on" | "1"
886 ),
887 })
888}
889
890fn default_ui_path() -> String {
891 "dist".to_string()
892}
893
894fn default_app_type_native() -> AppType {
895 AppType::Native
896}
897
898impl AppManifest {
899 pub fn resolved_requires(&self) -> Result<Vec<String>, String> {
903 let top = normalized_requirements(&self.requires)?;
904 let nested = normalized_requirements(&self.capabilities.requires)?;
905 if !top.is_empty()
906 && !nested.is_empty()
907 && top.iter().cloned().collect::<BTreeSet<_>>()
908 != nested.iter().cloned().collect::<BTreeSet<_>>()
909 {
910 return Err("top-level 'requires' conflicts with 'capabilities.requires'".to_string());
911 }
912 Ok(if !top.is_empty() { top } else { nested })
913 }
914
915 pub fn effective_hot_reload(&self) -> HotReloadKind {
918 self.hot_reload
919 .unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
920 }
921
922 pub fn effective_entrypoint(&self) -> &str {
928 if let Some(ref ep) = self.entrypoint {
929 ep.as_str()
930 } else {
931 match self.app_type {
932 AppType::Native => "app.so",
933 AppType::Bun => "dist/index.js",
934 AppType::Standalone => "",
935 AppType::PlatformRuntime => "bun",
936 AppType::ManagedV1 => "llmc-generated-app",
937 AppType::Burger => "dist/index.js",
938 AppType::UiOnly => "",
939 }
940 }
941 }
942
943 pub fn has_capability_providers(&self) -> bool {
946 !self.provides.is_empty() || !self.capabilities.provides.is_empty()
947 }
948
949 pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
963 let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
964 for raw in &self.capabilities.provides {
965 let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
966 if name.is_empty() {
967 continue;
968 }
969 out.entry(name).or_insert(ProvidedCapability {
970 description: String::new(),
971 schema: None,
972 });
973 }
974 out
975 }
976
977 pub fn validate(&self) -> Result<(), String> {
982 self.validate_with_socket_path_policy(false)
983 }
984
985 pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
990 if self.manifest_version == 2 && self.abi.is_none() {
992 return Err("manifest_version 2 requires an 'abi' field".to_string());
993 }
994
995 validate_app_name(&self.name)?;
998 self.resolved_requires()?;
999
1000 if let Some(ref ep) = self.entrypoint {
1002 validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
1003 }
1004 if !self.ui_path.is_empty() && self.ui_path != "dist" {
1006 validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
1007 }
1008
1009 if let Some(ui) = &self.ui {
1010 validate_app_ui(&self.name, ui)?;
1011 }
1012
1013 if let Some(ref hp) = self.homepage {
1015 if !hp.starts_with("https://") && !hp.starts_with("http://") {
1016 return Err(format!(
1017 "homepage '{}' must use https:// or http:// scheme",
1018 hp
1019 ));
1020 }
1021 }
1022
1023 match self.app_type {
1030 AppType::Standalone => {
1031 if self.has_capability_providers() {
1032 let cfg = self.standalone.as_ref().ok_or_else(|| {
1033 "standalone apps that declare 'provides' require a \
1034 'standalone.socket_path' field"
1035 .to_string()
1036 })?;
1037 validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
1038 }
1039 }
1040 AppType::Native
1041 | AppType::Bun
1042 | AppType::PlatformRuntime
1043 | AppType::ManagedV1
1044 | AppType::Burger
1045 | AppType::UiOnly => {
1046 if self.standalone.is_some() {
1047 return Err(format!(
1048 "'standalone' block is only valid when app_type == 'standalone' \
1049 (found app_type='{}')",
1050 self.app_type
1051 ));
1052 }
1053 }
1054 }
1055
1056 if self.app_type == AppType::UiOnly {
1057 validate_ui_only(self)?;
1058 }
1059
1060 if let Some(resources) = &self.resources {
1061 if resources.memory_mb == Some(0) {
1062 return Err("resources.memory_mb must be at least 1".to_string());
1063 }
1064 if let Some(deadline) = resources.callback_deadline_ms {
1065 if !(1..=MAX_CALLBACK_DEADLINE_MS).contains(&deadline) {
1066 return Err(format!(
1067 "resources.callback_deadline_ms must be between 1 and {MAX_CALLBACK_DEADLINE_MS} (found {deadline})"
1068 ));
1069 }
1070 }
1071 }
1072
1073 if self.app_type == AppType::PlatformRuntime
1074 && !self.resolved_capability_provides().is_empty()
1075 {
1076 return Err(
1077 "platform-runtime packages cannot provide runtime capabilities".to_string(),
1078 );
1079 }
1080
1081 Ok(())
1082 }
1083
1084 pub fn from_json(json: &str) -> Result<Self, String> {
1086 Self::from_json_with_socket_path_policy(json, false)
1087 }
1088
1089 pub fn from_json_with_socket_path_policy(
1091 json: &str,
1092 allow_non_run: bool,
1093 ) -> Result<Self, String> {
1094 let mut manifest: Self =
1095 serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
1096 if serde_json::from_str::<serde_json::Value>(json).is_ok_and(|raw| declares_ui_only(&raw)) {
1097 manifest.app_type = AppType::UiOnly;
1098 }
1099 if manifest.ui.is_some() {
1100 manifest.has_ui = true;
1101 }
1102 manifest.validate_with_socket_path_policy(allow_non_run)?;
1103 Ok(manifest)
1104 }
1105}
1106
1107fn normalized_requirements(values: &[String]) -> Result<Vec<String>, String> {
1108 let mut seen = HashSet::new();
1109 let mut resolved = Vec::new();
1110 for value in values {
1111 let requirement = value.trim();
1112 if requirement.is_empty() {
1113 return Err("capability requirements must not be blank".to_string());
1114 }
1115 if seen.insert(requirement.to_string()) {
1116 resolved.push(requirement.to_string());
1117 }
1118 }
1119 Ok(resolved)
1120}
1121
1122fn validate_ui_only(manifest: &AppManifest) -> Result<(), String> {
1124 match &manifest.ui {
1125 Some(ui) if ui.kind == AppUiKind::Stage => {}
1126 _ => return Err("ui-only apps must declare a ui block of kind \"stage\"".to_string()),
1127 }
1128 match ui_only_process_declaration(manifest) {
1129 Some(what) => Err(format!(
1130 "ui-only apps have no backend process and must not declare {what}"
1131 )),
1132 None => Ok(()),
1133 }
1134}
1135
1136pub fn ui_only_process_declaration(manifest: &AppManifest) -> Option<&'static str> {
1141 if manifest.entrypoint.is_some() {
1142 Some("'entrypoint'")
1143 } else if manifest.has_capability_providers() {
1144 Some("capability providers ('provides' / 'capabilities.provides')")
1145 } else if manifest.tcp.is_some() {
1146 Some("a 'tcp' block")
1147 } else if manifest.resources.is_some() {
1148 Some("a 'resources' block")
1149 } else if manifest.standalone.is_some() {
1150 Some("a 'standalone' block")
1151 } else if !manifest.subscribes.is_empty() {
1152 Some("event subscriptions ('subscribes')")
1153 } else if manifest
1154 .depends_on
1155 .as_ref()
1156 .is_some_and(|deps| !deps.is_empty())
1157 {
1158 Some("dependencies ('depends_on')")
1159 } else {
1160 None
1161 }
1162}
1163
1164pub fn declares_ui_only(manifest: &serde_json::Value) -> bool {
1177 manifest.as_object().is_some_and(|object| {
1178 let derived = object.get("ui").is_some_and(serde_json::Value::is_object)
1179 && !object.contains_key("app_type")
1180 && !object.contains_key("entrypoint");
1181 derived || object.get("app_type").and_then(serde_json::Value::as_str) == Some("ui-only")
1182 })
1183}
1184
1185fn validate_app_ui(app_name: &str, ui: &AppUiManifest) -> Result<(), String> {
1186 if ui.ui_api != 1 && ui.ui_api != 2 {
1187 return Err(format!(
1188 "ui.ui_api {} is unsupported; only versions 1 and 2 are supported",
1189 ui.ui_api
1190 ));
1191 }
1192 if ui.title.trim().is_empty() {
1193 return Err("ui.title must not be blank".to_string());
1194 }
1195 ui.requires.resolved()?;
1196 validate_manifest_path(&ui.entry).map_err(|error| format!("ui.entry invalid: {error}"))?;
1197 if let Some(icon) = &ui.icon {
1198 validate_manifest_path(icon).map_err(|error| format!("ui.icon invalid: {error}"))?;
1199 }
1200 if let Some(nav) = &ui.nav {
1201 if ui.kind == AppUiKind::Widget {
1202 return Err("widget ui must omit nav metadata".to_string());
1203 }
1204 if nav.section.trim().is_empty() {
1205 return Err("ui.nav.section must not be blank".to_string());
1206 }
1207 }
1208 if let Some(data) = &ui.data {
1209 if ui.kind != AppUiKind::Stage {
1210 return Err("widget ui must omit app data declarations".to_string());
1211 }
1212 validate_app_data(app_name, data, &ui.requires.resolved()?)?;
1213 }
1214
1215 let mut composed = HashSet::new();
1216 for name in &ui.composes {
1217 validate_app_name(name).map_err(|error| format!("ui.composes entry invalid: {error}"))?;
1218 if name == app_name {
1219 return Err("ui.composes must not contain the app itself".to_string());
1220 }
1221 if !composed.insert(name) {
1222 return Err(format!("ui.composes contains duplicate app '{name}'"));
1223 }
1224 }
1225
1226 let mut surface_ids = HashSet::new();
1227 for surface in &ui.surfaces {
1228 let id = surface.id.trim();
1229 if id.is_empty() {
1230 return Err("ui.surfaces entry id must not be blank".to_string());
1231 }
1232 if !surface_ids.insert(id.to_string()) {
1233 return Err(format!("ui.surfaces contains duplicate id '{id}'"));
1234 }
1235 if !KNOWN_SURFACE_SLOTS.contains(&surface.slot.as_str()) {
1236 return Err(format!(
1237 "ui.surfaces entry '{id}' requests unknown slot '{}'; known slots: {}",
1238 surface.slot,
1239 KNOWN_SURFACE_SLOTS.join(", ")
1240 ));
1241 }
1242 if surface.title.trim().is_empty() {
1243 return Err(format!("ui.surfaces entry '{id}' title must not be blank"));
1244 }
1245 validate_manifest_path(&surface.entry)
1246 .map_err(|error| format!("ui.surfaces entry '{id}' entry invalid: {error}"))?;
1247 if !ui.integrity.contains_key(&surface.entry) {
1256 return Err(format!("ui.integrity must include surface '{id}' entry"));
1257 }
1258 surface
1259 .requires
1260 .resolved()
1261 .map_err(|error| format!("ui.surfaces entry '{id}' requires invalid: {error}"))?;
1262 }
1263
1264 for (path, digest) in &ui.integrity {
1265 validate_manifest_path(path)
1266 .map_err(|error| format!("ui.integrity path invalid: {error}"))?;
1267 if !is_lowercase_sha256(digest) {
1268 return Err(format!(
1269 "ui.integrity digest for '{path}' must be a lowercase 64-character SHA-256"
1270 ));
1271 }
1272 }
1273 if !ui.integrity.contains_key(&ui.entry) {
1274 return Err("ui.integrity must include the declared entry".to_string());
1275 }
1276 if let Some(icon) = &ui.icon {
1277 if !ui.integrity.contains_key(icon) {
1278 return Err("ui.integrity must include the declared icon".to_string());
1279 }
1280 }
1281 Ok(())
1282}
1283
1284fn validate_app_data(
1285 app_name: &str,
1286 data: &AppDataManifest,
1287 resolved_requires: &[String],
1288) -> Result<(), String> {
1289 validate_app_data_namespace(&data.namespace)?;
1290 validate_app_data_namespace_owner(app_name, &data.namespace)?;
1291 validate_app_data_sync_policy(&data.sync)?;
1292 if data.queries.is_empty() {
1293 return Err("ui.data.queries must declare at least one query".to_string());
1294 }
1295
1296 let requires: BTreeSet<&str> = resolved_requires.iter().map(String::as_str).collect();
1297 let mut names = BTreeSet::new();
1298 for query in &data.queries {
1299 validate_namespaced_data_name(&query.name, &data.namespace)
1300 .map_err(|error| format!("ui.data query '{}' invalid: {error}", query.name))?;
1301 validate_capability_name(&query.capability).map_err(|error| {
1302 format!(
1303 "ui.data query '{}' capability '{}' invalid: {error}",
1304 query.name, query.capability
1305 )
1306 })?;
1307 if !requires.contains(query.capability.as_str()) {
1308 return Err(format!(
1309 "ui.data query '{}' capability '{}' must be declared in requires",
1310 query.name, query.capability
1311 ));
1312 }
1313 if !names.insert(query.name.as_str()) {
1314 return Err(format!("ui.data contains duplicate query '{}'", query.name));
1315 }
1316 }
1317
1318 for stream in &data.streams {
1319 validate_namespaced_data_name(&stream.name, &data.namespace)
1320 .map_err(|error| format!("ui.data stream '{}' invalid: {error}", stream.name))?;
1321 if !names.insert(stream.name.as_str()) {
1322 return Err(format!(
1323 "ui.data contains duplicate declaration '{}'",
1324 stream.name
1325 ));
1326 }
1327 }
1328
1329 Ok(())
1330}
1331
1332fn validate_app_data_namespace(namespace: &str) -> Result<(), String> {
1333 if !is_safe_name_segment(namespace) {
1334 return Err(format!(
1335 "ui.data namespace '{}' must match [a-z][a-z0-9-]*",
1336 namespace
1337 ));
1338 }
1339 if matches!(
1340 namespace,
1341 "core" | "internal" | "node" | "platform" | "system"
1342 ) {
1343 return Err(format!("ui.data namespace '{namespace}' is reserved"));
1344 }
1345 Ok(())
1346}
1347
1348pub fn validate_app_data_namespace_owner(app_name: &str, namespace: &str) -> Result<(), String> {
1359 if namespace != app_name {
1360 return Err(format!(
1361 "ui.data namespace '{namespace}' must equal the app name '{app_name}' — the Client \
1362 Node PWA refuses a stage whose data namespace it does not own; rename the namespace \
1363 to '{app_name}' and its query and stream names to '{app_name}.<name>.v<N>'"
1364 ));
1365 }
1366 Ok(())
1367}
1368
1369fn validate_app_data_sync_policy(sync: &AppDataSyncPolicy) -> Result<(), String> {
1370 validate_optional_range("cursor_ttl_secs", sync.cursor_ttl_secs, 60, 86_400)?;
1371 validate_optional_range(
1372 "full_refresh_interval_secs",
1373 sync.full_refresh_interval_secs,
1374 60,
1375 604_800,
1376 )?;
1377 validate_optional_range("retention_secs", sync.retention_secs, 300, 31_536_000)?;
1378 if sync.kind == AppDataSyncKind::Snapshot && sync.cursor_ttl_secs.is_some() {
1379 return Err("ui.data.sync cursor_ttl_secs is only valid for cursor sync".to_string());
1380 }
1381 Ok(())
1382}
1383
1384fn validate_optional_range(
1385 field: &str,
1386 value: Option<u32>,
1387 min: u32,
1388 max: u32,
1389) -> Result<(), String> {
1390 if let Some(value) = value {
1391 if value < min || value > max {
1392 return Err(format!(
1393 "ui.data.sync {field} must be between {min} and {max} seconds"
1394 ));
1395 }
1396 }
1397 Ok(())
1398}
1399
1400fn validate_namespaced_data_name(name: &str, namespace: &str) -> Result<(), String> {
1401 validate_capability_name(name)?;
1402 let Some(rest) = name
1403 .strip_prefix(namespace)
1404 .and_then(|suffix| suffix.strip_prefix('.'))
1405 else {
1406 return Err(format!("name must use namespace '{namespace}'"));
1407 };
1408 if rest.is_empty() {
1409 return Err("name must include a value after its namespace".to_string());
1410 }
1411 if !has_version_suffix(name) {
1412 return Err("name must end with a .vN version suffix".to_string());
1413 }
1414 Ok(())
1415}
1416
1417fn validate_capability_name(name: &str) -> Result<(), String> {
1418 if name.is_empty() {
1419 return Err("name must not be empty".to_string());
1420 }
1421 if name.contains('/') || name.contains("..") {
1422 return Err("name must not contain path separators or traversal".to_string());
1423 }
1424 if !name.split('.').all(is_safe_declaration_segment) {
1425 return Err("name must contain only lowercase dot-separated segments".to_string());
1426 }
1427 Ok(())
1428}
1429
1430fn validate_ui_requirement_name(name: &str, allow_wildcard: bool) -> Result<(), String> {
1431 if allow_wildcard && name.ends_with(".*") {
1432 return validate_capability_name(&name[..name.len() - 2]);
1433 }
1434 validate_capability_name(name)
1435}
1436
1437fn has_version_suffix(name: &str) -> bool {
1438 let Some(version) = name.rsplit('.').next() else {
1439 return false;
1440 };
1441 let Some(digits) = version.strip_prefix('v') else {
1442 return false;
1443 };
1444 !digits.is_empty()
1445 && !digits.starts_with('0')
1446 && digits.bytes().all(|byte| byte.is_ascii_digit())
1447}
1448
1449fn is_safe_name_segment(segment: &str) -> bool {
1450 if segment.is_empty() {
1451 return false;
1452 }
1453 let mut chars = segment.chars();
1454 let Some(first) = chars.next() else {
1455 return false;
1456 };
1457 first.is_ascii_lowercase()
1458 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
1459}
1460
1461fn is_safe_declaration_segment(segment: &str) -> bool {
1474 let mut chars = segment.chars();
1475 let Some(first) = chars.next() else {
1476 return false;
1477 };
1478 first.is_ascii_lowercase()
1479 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
1480}
1481
1482fn is_lowercase_sha256(value: &str) -> bool {
1483 value.len() == 64
1484 && value
1485 .bytes()
1486 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1487}
1488
1489pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
1498 validate_standalone_socket_path_with_policy(path, false)
1499}
1500
1501pub fn validate_standalone_socket_path_with_policy(
1503 path: &std::path::Path,
1504 allow_non_run: bool,
1505) -> Result<(), String> {
1506 if !path.is_absolute() {
1507 return Err(format!(
1508 "standalone.socket_path '{}' must be absolute",
1509 path.display()
1510 ));
1511 }
1512 if !allow_non_run && !path.starts_with("/run/") {
1513 return Err(format!(
1514 "standalone.socket_path '{}' must live under /run/",
1515 path.display()
1516 ));
1517 }
1518 if path
1519 .components()
1520 .any(|c| matches!(c, std::path::Component::ParentDir))
1521 {
1522 return Err(format!(
1523 "standalone.socket_path '{}' must not contain '..' segments",
1524 path.display()
1525 ));
1526 }
1527 Ok(())
1528}
1529
1530fn validate_app_name(name: &str) -> Result<(), String> {
1534 let (publisher, app) = if let Some(slash) = name.find('/') {
1535 let (p, rest) = name.split_at(slash);
1536 (Some(p), &rest[1..])
1537 } else {
1538 (None, name)
1539 };
1540
1541 let valid_segment = |s: &str| -> bool {
1542 if s.is_empty() {
1543 return false;
1544 }
1545 let mut chars = s.chars();
1546 let first = chars.next().unwrap();
1547 if !first.is_ascii_lowercase() {
1548 return false;
1549 }
1550 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1551 };
1552
1553 if let Some(pub_name) = publisher {
1554 if !valid_segment(pub_name) {
1555 return Err(format!(
1556 "publisher segment '{}' must match [a-z][a-z0-9-]*",
1557 pub_name
1558 ));
1559 }
1560 }
1561
1562 if !valid_segment(app) {
1563 return Err(format!(
1564 "app name segment '{}' must match [a-z][a-z0-9-]*",
1565 app
1566 ));
1567 }
1568
1569 Ok(())
1570}
1571
1572#[cfg(test)]
1575mod tests {
1576 use super::*;
1577
1578 fn parse_ok(json: &str) -> AppManifest {
1579 AppManifest::from_json(json).expect("should parse")
1580 }
1581
1582 fn parse_err(json: &str) -> String {
1583 AppManifest::from_json(json).expect_err("should fail")
1584 }
1585
1586 #[test]
1589 fn v1_minimal_native() {
1590 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1591 assert_eq!(m.manifest_version, 1);
1592 assert_eq!(m.app_type, AppType::Native);
1593 assert!(m.abi.is_none());
1594 }
1595
1596 #[test]
1597 fn v1_minimal_bun() {
1598 let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
1599 assert_eq!(m.app_type, AppType::Bun);
1600 assert_eq!(m.effective_entrypoint(), "dist/index.js");
1601 }
1602
1603 #[test]
1604 fn v1_no_manifest_version_field_defaults_to_1() {
1605 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1606 assert_eq!(m.manifest_version, 1);
1607 }
1608
1609 #[test]
1610 fn v1_all_optional_fields_missing() {
1611 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1612 assert!(!m.critical);
1613 assert!(m.auto_start);
1614 assert!(!m.has_ui);
1615 assert_eq!(m.ui_path, "dist");
1616 assert!(m.permissions.is_empty());
1617 assert!(m.optional_permissions.is_empty());
1618 assert!(m.governor.is_none());
1621 assert!(m.subscribes.is_empty());
1622 }
1623
1624 #[test]
1625 fn v1_with_permissions_and_provides() {
1626 let json = r#"{
1627 "name": "example",
1628 "version": "1.0.0",
1629 "app_type": "bun",
1630 "permissions": ["core.storage.kv"],
1631 "optional_permissions": ["core.notifications.create"],
1632 "provides": {
1633 "core.example.run": { "description": "Run example job" }
1634 }
1635 }"#;
1636 let m = parse_ok(json);
1637 assert_eq!(m.permissions, vec!["core.storage.kv"]);
1638 assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
1639 assert!(m.provides.contains_key("core.example.run"));
1640 }
1641
1642 #[test]
1645 fn v2_minimal_native() {
1646 let json = r#"{
1647 "manifest_version": 2,
1648 "name": "cron",
1649 "version": "1.0.0",
1650 "app_type": "native",
1651 "abi": "v1",
1652 "entrypoint": "app.so",
1653 "hot_reload": "experimental"
1654 }"#;
1655 let m = parse_ok(json);
1656 assert_eq!(m.manifest_version, 2);
1657 assert_eq!(m.abi, Some(AbiVersion::V1));
1658 assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
1659 assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
1660 }
1661
1662 #[test]
1663 fn v2_minimal_bun_with_capabilities() {
1664 let json = r#"{
1665 "manifest_version": 2,
1666 "name": "example-fullstack",
1667 "version": "1.0.0",
1668 "app_type": "bun",
1669 "abi": "v1",
1670 "entrypoint": "dist/index.js",
1671 "hot_reload": "supported",
1672 "has_ui": true,
1673 "ui_path": "ui/dist",
1674 "capabilities": {
1675 "requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
1676 "provides": []
1677 },
1678 "governor": { "terminable": false, "min_idle_secs": 300 },
1679 "subscribes": ["core.chat.message.received"]
1680 }"#;
1681 let m = parse_ok(json);
1682 assert_eq!(m.manifest_version, 2);
1683 assert_eq!(m.capabilities.requires.len(), 2);
1684 let governor = m.governor.expect("governor block should parse");
1686 assert_eq!(governor.terminable, Some(false));
1687 assert_eq!(governor.min_idle_secs, Some(300));
1688 assert_eq!(m.subscribes, vec!["core.chat.message.received"]);
1689 }
1690
1691 #[test]
1692 fn stage_contract_fixture_parses_with_normalized_requirements() {
1693 let json = include_str!(
1694 "../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
1695 );
1696 let m = parse_ok(json);
1697 assert!(m.has_ui);
1698 assert_eq!(m.resolved_requires().unwrap(), vec!["core.metrics.latest"]);
1699 let ui = m.ui.expect("fixture should declare ui");
1700 assert_eq!(ui.kind, AppUiKind::Stage);
1701 assert_eq!(ui.entry, "ui/main.js");
1702 assert_eq!(ui.nav.unwrap().order, 10);
1703 }
1704
1705 #[test]
1706 fn omitted_ui_keeps_legacy_flags_without_fabricating_a_stage() {
1707 let m = parse_ok(
1708 r#"{"name":"legacy","version":"1.0.0","app_type":"bun","has_ui":true,"ui_path":"ui/dist"}"#,
1709 );
1710 assert!(m.has_ui);
1711 assert_eq!(m.ui_path, "ui/dist");
1712 assert!(m.ui.is_none());
1713 }
1714
1715 #[test]
1716 fn ui_requirements_are_typed_serialized_and_separate_from_backend_requires() {
1717 let manifest = parse_ok(
1718 r#"{
1719 "name":"ui-contract","version":"1.0.0","app_type":"bun",
1720 "requires":["core.cron.register"],
1721 "ui":{
1722 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
1723 "requires":{
1724 "capabilities":["ui.snapshot.v1"],
1725 "queries":["ui.query.v1"],
1726 "streams":["ui.event.v1"]
1727 },
1728 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
1729 }
1730 }"#,
1731 );
1732
1733 assert_eq!(
1734 manifest.resolved_requires().unwrap(),
1735 vec!["core.cron.register"]
1736 );
1737 let ui = manifest.ui.as_ref().expect("ui requirements should parse");
1738 assert_eq!(ui.requires.capabilities, vec!["ui.snapshot.v1"]);
1739 assert_eq!(ui.requires.queries, vec!["ui.query.v1"]);
1740 assert_eq!(ui.requires.streams, vec!["ui.event.v1"]);
1741 assert_eq!(
1742 ui.requires.resolved().unwrap(),
1743 vec!["ui.snapshot.v1", "ui.query.v1", "ui.event.v1"]
1744 );
1745 let serialized = serde_json::to_value(ui).unwrap();
1746 assert_eq!(
1747 serialized["requires"]["queries"],
1748 serde_json::json!(["ui.query.v1"])
1749 );
1750 }
1751
1752 #[test]
1753 fn ui_requirements_reject_blank_entries() {
1754 let error = parse_err(
1755 r#"{
1756 "name":"ui-contract","version":"1.0.0","app_type":"bun",
1757 "ui":{
1758 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
1759 "requires":{"capabilities":[""],"queries":[],"streams":[]},
1760 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
1761 }
1762 }"#,
1763 );
1764 assert!(error.contains("ui.requires entries must not be blank"));
1765 }
1766
1767 #[test]
1768 fn ui_data_namespace_stays_hyphen_only_when_declarations_allow_underscore() {
1769 assert!(validate_app_data_namespace("obs-viewer").is_ok());
1776
1777 let error = validate_app_data_namespace("obs_viewer")
1778 .expect_err("underscore must not be admitted into a storage namespace");
1779 assert!(
1780 error.contains("must match [a-z][a-z0-9-]*"),
1781 "unexpected error: {error}"
1782 );
1783 }
1784
1785 #[test]
1786 fn ui_data_query_capability_does_not_fall_back_to_backend_requires() {
1787 let error = parse_err(
1788 r#"{
1789 "name":"ui-data","version":"1.0.0","app_type":"bun",
1790 "requires":["ui.snapshot.v1"],
1791 "ui":{
1792 "kind":"stage","entry":"ui/main.js","title":"UI data","ui_api":1,
1793 "requires":{"capabilities":[],"queries":[],"streams":[]},
1794 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1795 "data":{
1796 "namespace":"ui-data","offline":"last-known","sync":{"kind":"cursor"},
1797 "queries":[{"name":"ui-data.snapshot.v1","capability":"ui.snapshot.v1","kind":"snapshot"}],
1798 "streams":[]
1799 }
1800 }
1801 }"#,
1802 );
1803 assert!(error.contains("must be declared in requires"));
1804 }
1805
1806 #[test]
1811 fn ui_data_namespace_must_equal_the_app_name() {
1812 let manifest = |namespace: &str| {
1813 format!(
1814 r#"{{
1815 "name":"burger-runtime","version":"0.2.0",
1816 "ui":{{
1817 "kind":"stage","entry":"ui/dist/main.js","title":"Burger","ui_api":1,
1818 "requires":{{"capabilities":["core.runtime.burger_snapshot"]}},
1819 "integrity":{{"ui/dist/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},
1820 "data":{{
1821 "namespace":"{namespace}","offline":"online-only","sync":"snapshot",
1822 "queries":[{{"name":"{namespace}.snapshot.v1","capability":"core.runtime.burger_snapshot","kind":"snapshot"}}],
1823 "streams":[{{"name":"{namespace}.metrics.v1","kind":"events"}}]
1824 }}
1825 }}
1826 }}"#
1827 )
1828 };
1829
1830 let error = parse_err(&manifest("burger"));
1831 assert!(
1832 error.contains("ui.data namespace 'burger' must equal the app name 'burger-runtime'"),
1833 "unexpected error: {error}"
1834 );
1835
1836 let accepted = parse_ok(&manifest("burger-runtime"));
1837 let data = accepted.ui.as_ref().and_then(|ui| ui.data.as_ref());
1838 assert_eq!(
1839 data.map(|data| data.namespace.as_str()),
1840 Some("burger-runtime")
1841 );
1842 }
1843
1844 #[test]
1845 fn top_level_and_nested_requires_must_resolve_to_the_same_set() {
1846 let accepted = parse_ok(
1847 r#"{
1848 "name":"aliases","version":"1.0.0","app_type":"bun",
1849 "requires":["core.chat.read","core.chat.read","core.chat.send"],
1850 "capabilities":{"requires":["core.chat.send","core.chat.read"]}
1851 }"#,
1852 );
1853 assert_eq!(
1854 accepted.resolved_requires().unwrap(),
1855 vec!["core.chat.read", "core.chat.send"]
1856 );
1857
1858 let err = parse_err(
1859 r#"{
1860 "name":"aliases","version":"1.0.0","app_type":"bun",
1861 "requires":["core.chat.read"],
1862 "capabilities":{"requires":["core.wallet.pay"]}
1863 }"#,
1864 );
1865 assert!(err.contains("conflicts"), "unexpected error: {err}");
1866 }
1867
1868 #[test]
1869 fn stage_and_widget_ui_kinds_have_distinct_navigation_rules() {
1870 let base = |ui: &str| {
1871 format!(r#"{{"name":"stage","version":"1.0.0","app_type":"bun","ui":{ui}}}"#)
1872 };
1873 let widget = base(
1874 r#"{"kind":"widget","entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1875 );
1876 assert_eq!(
1877 parse_ok(&widget).ui.expect("widget ui").kind,
1878 AppUiKind::Widget
1879 );
1880 let widget_nav = base(
1881 r#"{"kind":"widget","entry":"ui/main.js","title":"Widget","nav":{"section":"default","order":1},"ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1882 );
1883 assert!(parse_err(&widget_nav).contains("must omit nav"));
1884 let api = base(
1885 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":2,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1886 );
1887 assert_eq!(parse_ok(&api).ui.expect("v2 stage ui").ui_api, 2);
1888 let unsupported_api = base(
1889 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":3,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1890 );
1891 assert!(parse_err(&unsupported_api).contains("ui_api"));
1892 let path = base(
1893 r#"{"kind":"stage","entry":"../main.js","title":"Stage","ui_api":1,"integrity":{"../main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1894 );
1895 assert!(parse_err(&path).contains("entry"));
1896 }
1897
1898 #[test]
1899 fn stage_requires_integrity_for_entry_and_icon() {
1900 let missing_entry = r#"{
1901 "name":"stage","version":"1.0.0","app_type":"bun",
1902 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{}}
1903 }"#;
1904 assert!(parse_err(missing_entry).contains("entry"));
1905 let missing_icon = r#"{
1906 "name":"stage","version":"1.0.0","app_type":"bun",
1907 "ui":{"entry":"ui/main.js","icon":"ui/icon.svg","title":"Stage","ui_api":1,
1908 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
1909 }"#;
1910 assert!(parse_err(missing_icon).contains("icon"));
1911 let uppercase = r#"{
1912 "name":"stage","version":"1.0.0","app_type":"bun",
1913 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
1914 "integrity":{"ui/main.js":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}
1915 }"#;
1916 assert!(parse_err(uppercase).contains("lowercase"));
1917 }
1918
1919 #[test]
1920 fn stage_composes_rejects_self_duplicate_and_unsafe_names() {
1921 let manifest = |composes: &str| {
1922 format!(
1923 r#"{{
1924 "name":"stage","version":"1.0.0","app_type":"bun",
1925 "ui":{{"entry":"ui/main.js","title":"Stage","ui_api":1,
1926 "composes":{composes},
1927 "integrity":{{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}}
1928 }}"#
1929 )
1930 };
1931 assert!(parse_err(&manifest(r#"["stage"]"#)).contains("itself"));
1932 assert!(parse_err(&manifest(r#"["chat","chat"]"#)).contains("duplicate"));
1933 assert!(parse_err(&manifest(r#"["../chat"]"#)).contains("invalid"));
1934 }
1935
1936 fn manifest_with_ui() -> AppManifest {
1948 parse_ok(
1949 r#"{
1950 "name":"stage","version":"1.0.0","app_type":"bun",
1951 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
1952 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1953 "ui/dist/surfaces/chip.js":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}
1954 }"#,
1955 )
1956 }
1957
1958 fn surface(id: &str, slot: &str) -> AppUiSurface {
1959 AppUiSurface {
1960 id: id.to_string(),
1961 slot: slot.to_string(),
1962 entry: "ui/dist/surfaces/chip.js".to_string(),
1963 title: "Chip".to_string(),
1964 order: 10,
1965 requires: AppUiRequirements {
1966 capabilities: vec!["wallet.balance.get".to_string()],
1967 ..Default::default()
1968 },
1969 }
1970 }
1971
1972 #[test]
1973 fn manifest_without_surfaces_still_parses() {
1974 let manifest = manifest_with_ui();
1975 assert!(manifest.ui.as_ref().unwrap().surfaces.is_empty());
1976 assert!(manifest.validate().is_ok());
1977 }
1978
1979 #[test]
1980 fn surface_in_a_known_slot_is_accepted() {
1981 let mut manifest = manifest_with_ui();
1982 manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "status-rail")];
1983 assert!(manifest.validate().is_ok());
1984 }
1985
1986 #[test]
1987 fn surface_in_an_unknown_slot_is_rejected() {
1988 let mut manifest = manifest_with_ui();
1989 manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "menu-bar")];
1990 let error = manifest.validate().unwrap_err();
1991 assert!(error.contains("menu-bar"), "unexpected error: {error}");
1992 }
1993
1994 #[test]
1995 fn duplicate_surface_ids_are_rejected() {
1996 let mut manifest = manifest_with_ui();
1997 manifest.ui.as_mut().unwrap().surfaces = vec![
1998 surface("chip", "status-rail"),
1999 surface("chip", "status-rail"),
2000 ];
2001 let error = manifest.validate().unwrap_err();
2002 assert!(error.contains("duplicate"), "unexpected error: {error}");
2003 }
2004
2005 #[test]
2006 fn surface_with_a_blank_id_is_rejected() {
2007 let mut manifest = manifest_with_ui();
2008 manifest.ui.as_mut().unwrap().surfaces = vec![surface(" ", "status-rail")];
2009 assert!(manifest.validate().is_err());
2010 }
2011
2012 #[test]
2013 fn surface_with_an_unsafe_entry_path_is_rejected() {
2014 let mut manifest = manifest_with_ui();
2015 let mut bad = surface("chip", "status-rail");
2016 bad.entry = "../../etc/passwd".to_string();
2017 manifest.ui.as_mut().unwrap().surfaces = vec![bad];
2018 assert!(manifest.validate().is_err());
2019 }
2020
2021 #[test]
2022 fn surface_entry_missing_from_integrity_is_rejected() {
2023 let mut manifest = manifest_with_ui();
2027 let mut unpinned = surface("chip", "status-rail");
2028 unpinned.entry = "ui/dist/surfaces/typo.js".to_string();
2029 manifest.ui.as_mut().unwrap().surfaces = vec![unpinned];
2030 let error = manifest.validate().unwrap_err();
2031 assert!(
2032 error.contains("ui.integrity must include surface 'chip' entry"),
2033 "unexpected error: {error}"
2034 );
2035 }
2036
2037 #[test]
2038 fn surface_with_a_blank_required_capability_is_rejected() {
2039 let mut manifest = manifest_with_ui();
2040 let mut bad = surface("chip", "status-rail");
2041 bad.requires.capabilities = vec![" ".to_string()];
2042 manifest.ui.as_mut().unwrap().surfaces = vec![bad];
2043 assert!(manifest.validate().is_err());
2044 }
2045
2046 #[test]
2047 fn v2_missing_abi_is_error() {
2048 let json = r#"{
2049 "manifest_version": 2,
2050 "name": "example",
2051 "version": "1.0.0",
2052 "app_type": "bun"
2053 }"#;
2054 let err = parse_err(json);
2055 assert!(err.contains("abi"), "expected abi error, got: {}", err);
2056 }
2057
2058 #[test]
2061 fn publisher_prefixed_name_accepted() {
2062 let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
2063 assert_eq!(m.name, "alice/weather");
2064 }
2065
2066 #[test]
2067 fn double_slash_name_rejected() {
2068 let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
2069 assert!(!err.is_empty());
2070 }
2071
2072 #[test]
2075 fn name_starting_with_digit_rejected() {
2076 let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
2077 assert!(!err.is_empty());
2078 }
2079
2080 #[test]
2081 fn name_with_uppercase_rejected() {
2082 let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
2083 assert!(!err.is_empty());
2084 }
2085
2086 #[test]
2087 fn empty_name_rejected() {
2088 let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
2089 assert!(!err.is_empty());
2090 }
2091
2092 #[test]
2095 fn path_traversal_double_dot_rejected() {
2096 let json = r#"{
2097 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2098 "app_type": "bun", "abi": "v1",
2099 "entrypoint": "../etc/passwd"
2100 }"#;
2101 let err = parse_err(json);
2102 assert!(err.contains(".."), "expected traversal error, got: {}", err);
2103 }
2104
2105 #[test]
2106 fn path_traversal_encoded_dot_not_decoded() {
2107 let json = r#"{
2109 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2110 "app_type": "bun", "abi": "v1",
2111 "entrypoint": "foo/../bar"
2112 }"#;
2113 let err = parse_err(json);
2114 assert!(!err.is_empty(), "should have failed: {}", err);
2115 }
2116
2117 #[test]
2118 fn absolute_path_rejected() {
2119 let json = r#"{
2120 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2121 "app_type": "bun", "abi": "v1",
2122 "entrypoint": "/usr/bin/sh"
2123 }"#;
2124 let err = parse_err(json);
2125 assert!(
2126 err.contains("absolute"),
2127 "expected absolute error, got: {}",
2128 err
2129 );
2130 }
2131
2132 #[test]
2133 fn shell_metachar_in_path_rejected() {
2134 let json = r#"{
2135 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2136 "app_type": "bun", "abi": "v1",
2137 "entrypoint": "dist/index.js;rm -rf /"
2138 }"#;
2139 let err = parse_err(json);
2140 assert!(!err.is_empty());
2141 }
2142
2143 #[test]
2144 fn valid_nested_path_accepted() {
2145 let json = r#"{
2146 "manifest_version": 2, "name": "my-app", "version": "1.0.0",
2147 "app_type": "bun", "abi": "v1",
2148 "entrypoint": "dist/index.js",
2149 "ui_path": "ui/dist"
2150 }"#;
2151 parse_ok(json);
2152 }
2153
2154 #[test]
2157 fn homepage_https_accepted() {
2158 let json = r#"{
2159 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2160 "homepage": "https://example.com"
2161 }"#;
2162 parse_ok(json);
2163 }
2164
2165 #[test]
2166 fn homepage_javascript_scheme_rejected() {
2167 let json = r#"{
2168 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2169 "homepage": "javascript:alert(1)"
2170 }"#;
2171 let err = parse_err(json);
2172 assert!(
2173 err.contains("scheme"),
2174 "expected scheme error, got: {}",
2175 err
2176 );
2177 }
2178
2179 #[test]
2180 fn homepage_file_scheme_rejected() {
2181 let json = r#"{
2182 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2183 "homepage": "file:///etc/passwd"
2184 }"#;
2185 let err = parse_err(json);
2186 assert!(!err.is_empty());
2187 }
2188
2189 #[test]
2192 fn effective_entrypoint_native_default() {
2193 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2194 assert_eq!(m.effective_entrypoint(), "app.so");
2195 }
2196
2197 #[test]
2198 fn effective_entrypoint_bun_default() {
2199 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2200 assert_eq!(m.effective_entrypoint(), "dist/index.js");
2201 }
2202
2203 #[test]
2204 fn effective_hot_reload_native_default_is_experimental() {
2205 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2206 assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
2207 }
2208
2209 #[test]
2210 fn effective_hot_reload_bun_default_is_supported() {
2211 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2212 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
2213 }
2214
2215 #[test]
2216 fn hot_reload_unsupported_explicit() {
2217 let json = r#"{
2218 "manifest_version": 2, "name": "myapp", "version": "1.0.0",
2219 "app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
2220 }"#;
2221 let m = parse_ok(json);
2222 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
2223 }
2224
2225 #[test]
2228 fn validate_path_simple_valid() {
2229 assert!(validate_manifest_path("dist/index.js").is_ok());
2230 assert!(validate_manifest_path("app.so").is_ok());
2231 assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
2232 assert!(validate_manifest_path("build_output/main").is_ok());
2233 }
2234
2235 #[test]
2236 fn validate_path_empty_rejected() {
2237 assert!(validate_manifest_path("").is_err());
2238 }
2239
2240 #[test]
2241 fn validate_path_absolute_rejected() {
2242 assert!(validate_manifest_path("/usr/bin/sh").is_err());
2243 }
2244
2245 #[test]
2246 fn validate_path_double_dot_segment_rejected() {
2247 assert!(validate_manifest_path("foo/../bar").is_err());
2248 assert!(validate_manifest_path("../etc/passwd").is_err());
2249 }
2250
2251 #[test]
2252 fn validate_path_leading_dot_rejected() {
2253 assert!(validate_manifest_path(".hidden").is_err());
2254 }
2255
2256 #[test]
2257 fn validate_path_null_byte_rejected() {
2258 let path = "foo\0bar";
2260 assert!(validate_manifest_path(path).is_err());
2261 }
2262
2263 #[test]
2264 fn standalone_socket_path_development_override_is_explicit_and_pure() {
2265 let path = std::path::Path::new("/tmp/node-app/example.sock");
2266 assert!(validate_standalone_socket_path(path).is_err());
2267 assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
2268 assert!(validate_standalone_socket_path_with_policy(
2269 std::path::Path::new("/tmp/node-app/../escape.sock"),
2270 true,
2271 )
2272 .is_err());
2273 }
2274
2275 #[test]
2278 fn manifest_capabilities_defaults_to_empty() {
2279 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2280 assert!(m.capabilities.requires.is_empty());
2281 assert!(m.capabilities.provides.is_empty());
2282 }
2283
2284 #[test]
2287 fn resolved_capability_provides_v1_only() {
2288 let json = r#"{
2289 "name": "example", "version": "1.0.0", "app_type": "bun",
2290 "provides": { "core.example.run": { "description": "Run example job" } }
2291 }"#;
2292 let m = parse_ok(json);
2293 let out = m.resolved_capability_provides();
2294 assert_eq!(out.len(), 1);
2295 assert_eq!(
2296 out.get("core.example.run").unwrap().description,
2297 "Run example job"
2298 );
2299 }
2300
2301 #[test]
2302 fn resolved_capability_provides_v2_names_get_blank_declaration() {
2303 let json = r#"{
2304 "manifest_version": 2, "name": "example", "version": "1.0.0",
2305 "app_type": "bun", "abi": "v1",
2306 "capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
2307 }"#;
2308 let m = parse_ok(json);
2309 let out = m.resolved_capability_provides();
2310 assert_eq!(out.len(), 2);
2311 assert_eq!(out.get("core.example.run").unwrap().description, "");
2312 assert!(out.get("core.example.run").unwrap().schema.is_none());
2313 assert!(out.contains_key("core.example.other"));
2315 assert!(!out.contains_key("core.example.other:extra"));
2316 }
2317
2318 #[test]
2319 fn resolved_capability_provides_v1_wins_on_conflict() {
2320 let json = r#"{
2321 "manifest_version": 2, "name": "example", "version": "1.0.0",
2322 "app_type": "bun", "abi": "v1",
2323 "provides": { "core.example.run": { "description": "v1 wins" } },
2324 "capabilities": { "requires": [], "provides": ["core.example.run"] }
2325 }"#;
2326 let m = parse_ok(json);
2327 let out = m.resolved_capability_provides();
2328 assert_eq!(out.len(), 1);
2329 assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
2330 }
2331
2332 #[test]
2333 fn resolved_capability_provides_blank_v2_name_skipped() {
2334 let json = r#"{
2335 "manifest_version": 2, "name": "example", "version": "1.0.0",
2336 "app_type": "bun", "abi": "v1",
2337 "capabilities": { "requires": [], "provides": [" ", "core.example.run"] }
2338 }"#;
2339 let m = parse_ok(json);
2340 let out = m.resolved_capability_provides();
2341 assert_eq!(out.len(), 1);
2342 assert!(out.contains_key("core.example.run"));
2343 }
2344
2345 #[test]
2346 fn resolved_capability_provides_empty_manifest_yields_empty_map() {
2347 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2348 assert!(m.resolved_capability_provides().is_empty());
2349 }
2350
2351 #[test]
2354 fn abi_v1_is_supported() {
2355 assert!(AbiVersion::V1.is_supported());
2356 }
2357
2358 #[test]
2361 fn app_tier_display() {
2362 assert_eq!(AppTier::FirstParty.to_string(), "first_party");
2363 assert_eq!(AppTier::Optional.to_string(), "optional");
2364 assert_eq!(AppTier::Development.to_string(), "development");
2365 }
2366
2367 #[test]
2368 fn app_tier_serde_roundtrip() {
2369 for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
2371 let json = serde_json::to_string(&tier).unwrap();
2372 let back: AppTier = serde_json::from_str(&json).unwrap();
2373 assert_eq!(
2374 tier, back,
2375 "roundtrip failed for {:?}: serialized as {}",
2376 tier, json
2377 );
2378 }
2379 assert_eq!(
2380 serde_json::to_string(&AppTier::Development).unwrap(),
2381 "\"development\""
2382 );
2383 }
2384
2385 #[test]
2388 fn app_type_display() {
2389 assert_eq!(AppType::Native.to_string(), "native");
2390 assert_eq!(AppType::Bun.to_string(), "bun");
2391 assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
2392 }
2393
2394 #[test]
2395 fn platform_runtime_is_a_supported_packaging_type() {
2396 let manifest: AppManifest = serde_json::from_value(serde_json::json!({
2397 "manifest_version": 2,
2398 "abi": "v1",
2399 "name": "bun-runtime",
2400 "version": "1.0.0",
2401 "app_type": "platform-runtime",
2402 "entrypoint": "bun"
2403 }))
2404 .expect("platform runtime manifest should parse");
2405
2406 assert_eq!(manifest.app_type, AppType::PlatformRuntime);
2407 assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
2408 }
2409
2410 #[test]
2413 fn governor_manifest_memory_budget_defaults_to_none() {
2414 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2415 assert_eq!(parsed.memory_budget_kb, None);
2416 }
2417
2418 #[test]
2419 fn governor_manifest_parses_declared_memory_budget() {
2420 let parsed: GovernorManifest =
2421 serde_json::from_str(r#"{"memory_budget_kb": 40960}"#).unwrap();
2422 assert_eq!(parsed.memory_budget_kb, Some(40_960));
2423 }
2424
2425 #[test]
2428 fn latency_class_parses_and_defaults_none() {
2429 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2430 assert_eq!(parsed.latency_class, None);
2431 }
2432
2433 #[test]
2434 fn latency_class_parses_declared_interactive() {
2435 let json = r#"{
2436 "name": "example",
2437 "version": "1.0.0",
2438 "app_type": "bun",
2439 "governor": {"latency_class": "interactive"}
2440 }"#;
2441 let m = parse_ok(json);
2442 let governor = m.governor.expect("governor block should parse");
2443 assert_eq!(governor.latency_class, Some(LatencyClass::Interactive));
2444 }
2445
2446 #[test]
2447 fn latency_class_parses_declared_background() {
2448 let parsed: GovernorManifest =
2449 serde_json::from_str(r#"{"latency_class": "background"}"#).unwrap();
2450 assert_eq!(parsed.latency_class, Some(LatencyClass::Background));
2451 }
2452
2453 #[test]
2454 fn latency_class_invalid_value_is_parse_error() {
2455 let result: Result<GovernorManifest, _> =
2456 serde_json::from_str(r#"{"latency_class": "urgent"}"#);
2457 assert!(result.is_err(), "unknown latency_class value must fail to parse");
2458 }
2459
2460 #[test]
2461 fn latency_class_as_str_and_from_str_roundtrip() {
2462 for class in [LatencyClass::Interactive, LatencyClass::Background] {
2463 let s = class.as_str();
2464 assert_eq!(LatencyClass::from_str(s), Ok(class));
2465 }
2466 assert!(LatencyClass::from_str("urgent").is_err());
2467 }
2468
2469 fn ui_only_manifest() -> serde_json::Value {
2472 serde_json::json!({
2473 "manifest_version": 2, "abi": "v1", "name": "burger-runtime", "version": "1.0.0",
2474 "auto_start": false, "has_ui": true, "ui_path": "ui/dist",
2475 "ui": {
2476 "kind": "stage", "entry": "ui/dist/main.js", "title": "Burger", "icon": "ui/dist/icon.svg",
2477 "nav": { "section": "system", "order": 90 }, "ui_api": 1,
2478 "requires": {
2479 "capabilities": ["core.runtime.burger_snapshot", "core.runtime.burger_logs"],
2480 "queries": [], "streams": ["app.burger_metrics"]
2481 },
2482 "data": {
2483 "namespace": "burger-runtime", "offline": "online-only", "sync": "snapshot",
2484 "queries": [{ "name": "burger-runtime.snapshot.v1", "capability": "core.runtime.burger_snapshot", "kind": "snapshot" }],
2485 "streams": [{ "name": "burger-runtime.metrics.v1", "kind": "events" }]
2486 },
2487 "integrity": { "ui/dist/main.js": "a".repeat(64), "ui/dist/icon.svg": "b".repeat(64) }
2488 }
2489 })
2490 }
2491
2492 fn ui_only_with(key: &str, value: serde_json::Value) -> String {
2493 let mut manifest = ui_only_manifest();
2494 manifest[key] = value;
2495 manifest.to_string()
2496 }
2497
2498 #[test]
2499 fn a_stage_manifest_without_app_type_or_entrypoint_is_ui_only() {
2500 let m = parse_ok(&ui_only_manifest().to_string());
2501 assert_eq!(m.app_type, AppType::UiOnly);
2502 assert_eq!(m.app_type.as_str(), "ui-only");
2503 assert_eq!(m.effective_entrypoint(), "", "no backend, no entrypoint");
2504 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
2505 assert!(m.has_ui);
2506 let ui = m.ui.as_ref().expect("a ui block");
2507 assert_eq!(ui.kind, AppUiKind::Stage);
2508 let data = ui.data.as_ref().expect("the F5 ui.data block validates");
2509 assert_eq!(data.namespace, "burger-runtime");
2510 assert_eq!(data.offline, AppDataOfflinePolicy::OnlineOnly);
2511 assert_eq!(data.queries[0].capability, "core.runtime.burger_snapshot");
2512 assert_eq!(data.streams[0].name, "burger-runtime.metrics.v1");
2513 }
2514
2515 #[test]
2516 fn a_ui_only_manifest_round_trips_through_its_serialized_form() {
2517 let m = parse_ok(&ui_only_manifest().to_string());
2518 let wire = serde_json::to_string(&m).unwrap();
2519 assert!(wire.contains(r#""app_type":"ui-only""#), "{wire}");
2520 assert_eq!(parse_ok(&wire).app_type, AppType::UiOnly);
2521 }
2522
2523 #[test]
2524 fn a_ui_only_manifest_refuses_everything_that_implies_a_process() {
2525 for (key, value) in [
2526 (
2527 "provides",
2528 serde_json::json!({ "burger.runtime.peek": { "description": "x" } }),
2529 ),
2530 (
2531 "capabilities",
2532 serde_json::json!({ "provides": ["burger.runtime.peek"] }),
2533 ),
2534 ("tcp", serde_json::json!({ "preferred_port": 7010 })),
2535 ("resources", serde_json::json!({ "memory_mb": 16 })),
2536 (
2537 "standalone",
2538 serde_json::json!({ "socket_path": "/run/node/x.sock" }),
2539 ),
2540 ("subscribes", serde_json::json!(["system.network.changed"])),
2541 ("depends_on", serde_json::json!(["cron"])),
2542 ] {
2543 let err = parse_err(&ui_only_with(key, value));
2544 assert!(err.contains("ui-only"), "{key}: {err}");
2545 }
2546 let mut explicit_with_entry = ui_only_manifest();
2547 explicit_with_entry["app_type"] = serde_json::json!("ui-only");
2548 explicit_with_entry["entrypoint"] = serde_json::json!("dist/index.js");
2549 assert!(parse_err(&explicit_with_entry.to_string()).contains("'entrypoint'"));
2550 }
2551
2552 #[test]
2553 fn a_ui_only_manifest_must_declare_a_stage() {
2554 let mut widget = ui_only_manifest();
2555 widget["ui"]["kind"] = serde_json::json!("widget");
2556 let widget_ui = widget["ui"].as_object_mut().unwrap();
2557 widget_ui.remove("nav");
2558 widget_ui.remove("data");
2561 assert!(parse_err(&widget.to_string()).contains(r#"kind "stage""#));
2562 let no_ui = r#"{"manifest_version":2,"abi":"v1","name":"x","version":"1.0.0","app_type":"ui-only"}"#;
2563 assert!(parse_err(no_ui).contains(r#"kind "stage""#));
2564 }
2565
2566 #[test]
2567 fn declaring_app_type_or_entrypoint_keeps_the_existing_defaults() {
2568 assert_eq!(
2569 parse_ok(r#"{"name":"cron","version":"1.0.0"}"#).app_type,
2570 AppType::Native
2571 );
2572 let mut with_entry = ui_only_manifest();
2573 with_entry["entrypoint"] = serde_json::json!("app.so");
2574 assert_eq!(parse_ok(&with_entry.to_string()).app_type, AppType::Native);
2575 let mut bun = ui_only_manifest();
2576 bun["app_type"] = serde_json::json!("bun");
2577 assert_eq!(parse_ok(&bun.to_string()).app_type, AppType::Bun);
2578 }
2579
2580 #[test]
2581 fn declares_ui_only_accepts_the_derived_and_the_explicit_form_only() {
2582 assert!(declares_ui_only(&ui_only_manifest()));
2583 assert!(declares_ui_only(
2584 &serde_json::json!({ "app_type": "ui-only" })
2585 ));
2586 assert!(!declares_ui_only(
2587 &serde_json::json!({ "app_type": "bun", "ui": {} })
2588 ));
2589 assert!(!declares_ui_only(
2590 &serde_json::json!({ "entrypoint": "app.so", "ui": {} })
2591 ));
2592 assert!(!declares_ui_only(&serde_json::json!({ "name": "cron" })));
2593 assert!(!declares_ui_only(&serde_json::json!("ui-only")));
2594 }
2595}
2596
2597#[cfg(test)]
2598mod shared_app_data_corpus_tests {
2599 use super::*;
2600
2601 #[test]
2606 fn shared_app_data_corpus_matches_the_host_validator() {
2607 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/app-data");
2608 let mut checked = 0;
2609 for entry in std::fs::read_dir(&dir).expect("fixture directory must exist") {
2610 let path = entry.expect("readable entry").path();
2611 if path.extension().and_then(|e| e.to_str()) != Some("json") {
2612 continue;
2613 }
2614 let fixture: serde_json::Value =
2615 serde_json::from_str(&std::fs::read_to_string(&path).expect("readable fixture"))
2616 .expect("valid fixture json");
2617 let name = path
2618 .file_name()
2619 .and_then(|n| n.to_str())
2620 .unwrap_or("?")
2621 .to_string();
2622 let manifest = serde_json::json!({
2626 "manifest_version": 2, "abi": "v1", "name": "notes", "version": "0.1.0",
2627 "app_type": "bun", "entrypoint": "dist/index.js",
2628 "ui": fixture["ui"],
2629 });
2630 let result = AppManifest::from_json(&manifest.to_string()).and_then(|m| m.validate());
2631 match fixture["expect"].as_str().expect("expect field") {
2632 "accept" => assert!(result.is_ok(), "{name} should be accepted: {result:?}"),
2633 "reject" => {
2634 let error = result.expect_err(&format!("{name} should be rejected"));
2635 let reason = fixture["reason"]
2636 .as_str()
2637 .expect("reject fixtures need a reason");
2638 assert!(
2639 error.contains(reason),
2640 "{name}: {error:?} should mention {reason:?}"
2641 );
2642 }
2643 other => panic!("{name}: unknown expect {other:?}"),
2644 }
2645 checked += 1;
2646 }
2647 assert!(checked >= 10, "expected the full corpus, walked {checked}");
2649 }
2650}
2651
2652#[cfg(test)]
2653mod burger_manifest_tests {
2654 use super::*;
2655
2656 #[test]
2657 fn burger_app_type_parses_with_bundle_entrypoint_default() {
2658 let m = AppManifest::from_json(r#"{"name":"did","version":"2.0.0","app_type":"burger"}"#)
2659 .expect("burger manifest parses");
2660 assert_eq!(m.app_type, AppType::Burger);
2661 assert_eq!(m.app_type.as_str(), "burger");
2662 assert_eq!(m.effective_entrypoint(), "dist/index.js");
2663 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
2664 assert!(m.resources.is_none());
2665 }
2666
2667 #[test]
2668 fn resources_memory_mb_is_carried() {
2669 let m = AppManifest::from_json(
2670 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"memory_mb":48}}"#,
2671 )
2672 .expect("resources block parses");
2673 assert_eq!(
2674 m.resources,
2675 Some(ResourcesManifest { memory_mb: Some(48), callback_deadline_ms: None })
2676 );
2677 }
2678
2679 #[test]
2680 fn resources_callback_deadline_ms_is_carried() {
2681 let m = AppManifest::from_json(
2682 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"callback_deadline_ms":750}}"#,
2683 )
2684 .expect("callback deadline parses");
2685 assert_eq!(
2686 m.resources,
2687 Some(ResourcesManifest { memory_mb: None, callback_deadline_ms: Some(750) })
2688 );
2689 let max = AppManifest::from_json(
2690 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"callback_deadline_ms":600000}}"#,
2691 )
2692 .expect("the maximum is inclusive");
2693 assert_eq!(
2694 max.resources.and_then(|r| r.callback_deadline_ms),
2695 Some(MAX_CALLBACK_DEADLINE_MS)
2696 );
2697 }
2698
2699 #[test]
2700 fn resources_callback_deadline_ms_out_of_range_is_rejected() {
2701 for bad in ["0", "600001"] {
2702 let json = format!(
2703 r#"{{"name":"did","version":"2.0.0","app_type":"burger","resources":{{"callback_deadline_ms":{bad}}}}}"#
2704 );
2705 let err = AppManifest::from_json(&json).expect_err("out-of-range callback deadline");
2706 assert!(err.contains("resources.callback_deadline_ms"), "{bad}: {err}");
2707 }
2708 }
2709
2710 #[test]
2711 fn resources_callback_deadline_ms_must_be_a_positive_integer() {
2712 for bad in ["-1", "1.5", "\"5000\""] {
2713 let json = format!(
2714 r#"{{"name":"did","version":"2.0.0","app_type":"burger","resources":{{"callback_deadline_ms":{bad}}}}}"#
2715 );
2716 assert!(
2717 AppManifest::from_json(&json).is_err(),
2718 "callback_deadline_ms={bad} must be rejected"
2719 );
2720 }
2721 }
2722
2723 #[test]
2724 fn resources_memory_mb_zero_is_rejected() {
2725 let err = AppManifest::from_json(
2726 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"memory_mb":0}}"#,
2727 )
2728 .expect_err("a zero memory limit is invalid");
2729 assert!(err.contains("resources.memory_mb"), "{err}");
2730 }
2731
2732 #[test]
2733 fn burger_manifest_rejects_a_standalone_block() {
2734 let err = AppManifest::from_json(
2735 r#"{"name":"did","version":"2.0.0","app_type":"burger","standalone":{"socket_path":"/run/node-app-did.sock"}}"#,
2736 )
2737 .expect_err("standalone block is only valid for standalone apps");
2738 assert!(err.contains("only valid when app_type == 'standalone'"), "{err}");
2739 }
2740}