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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
728 pub schedules: Vec<AppScheduleManifest>,
729
730 #[serde(default, skip_serializing_if = "Option::is_none")]
734 pub host_contract: Option<crate::HostContractStamp>,
735}
736
737pub const MAX_CALLBACK_DEADLINE_MS: u32 = 600_000;
739
740#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
746pub struct ResourcesManifest {
747 #[serde(default, skip_serializing_if = "Option::is_none")]
748 pub memory_mb: Option<u32>,
749 #[serde(default, skip_serializing_if = "Option::is_none")]
750 pub callback_deadline_ms: Option<u32>,
751}
752
753#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
759#[serde(rename_all = "lowercase")]
760pub enum LatencyClass {
761 Interactive,
764 Background,
767}
768
769impl LatencyClass {
770 pub fn as_str(&self) -> &'static str {
771 match self {
772 LatencyClass::Interactive => "interactive",
773 LatencyClass::Background => "background",
774 }
775 }
776
777 #[allow(clippy::should_implement_trait)]
778 pub fn from_str(s: &str) -> Result<Self, String> {
779 match s {
780 "interactive" => Ok(LatencyClass::Interactive),
781 "background" => Ok(LatencyClass::Background),
782 _ => Err(format!("Invalid LatencyClass: {}", s)),
783 }
784 }
785}
786
787impl std::fmt::Display for LatencyClass {
788 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
789 write!(f, "{}", self.as_str())
790 }
791}
792
793#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
796pub struct GovernorManifest {
797 #[serde(default)]
801 pub terminable: Option<bool>,
802
803 #[serde(default)]
807 pub min_idle_secs: Option<u64>,
808
809 #[serde(default)]
842 pub memory_budget_kb: Option<u64>,
843
844 #[serde(default)]
849 pub latency_class: Option<LatencyClass>,
850}
851
852#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
856pub struct TcpManifest {
857 #[serde(default, skip_serializing_if = "Option::is_none")]
861 pub preferred_port: Option<u16>,
862
863 #[serde(default, skip_serializing_if = "Option::is_none")]
870 pub direct_bind: Option<bool>,
871}
872
873#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
890pub struct AppScheduleManifest {
891 pub id: String,
898
899 pub cron: String,
908
909 pub capability: String,
917
918 #[serde(default, skip_serializing_if = "Option::is_none")]
922 pub payload: Option<serde_json::Value>,
923}
924
925impl AppScheduleManifest {
926 pub fn effective_payload(&self) -> serde_json::Value {
928 self.payload
929 .clone()
930 .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()))
931 }
932}
933
934fn cron_field_char_allowed(ch: char) -> bool {
939 ch.is_ascii_alphanumeric() || matches!(ch, '*' | ',' | '-' | '/' | '?' | 'L' | 'W' | '#')
940}
941
942fn validate_cron_shape(expression: &str) -> Result<(), String> {
945 let fields: Vec<&str> = expression.split_whitespace().collect();
946 if fields.len() != 6 {
947 return Err(format!(
948 "cron '{}' must have 6 fields (sec min hour day month weekday), found {}",
949 expression,
950 fields.len()
951 ));
952 }
953 for field in fields {
954 if let Some(ch) = field.chars().find(|c| !cron_field_char_allowed(*c)) {
955 return Err(format!(
956 "cron '{}' contains disallowed character '{}'",
957 expression, ch
958 ));
959 }
960 }
961 Ok(())
962}
963
964fn validate_schedule_id(id: &str) -> Result<(), String> {
967 if id.is_empty() {
968 return Err("schedule id must not be empty".to_string());
969 }
970 let first = id.chars().next().unwrap();
971 if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
972 return Err(format!(
973 "schedule id '{}' must begin with a lowercase letter or digit",
974 id
975 ));
976 }
977 for ch in id.chars() {
978 if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && !matches!(ch, '-' | '_' | '.') {
979 return Err(format!(
980 "schedule id '{}' contains disallowed character '{}' (allowed: a-z 0-9 - _ .)",
981 id, ch
982 ));
983 }
984 }
985 Ok(())
986}
987
988fn default_manifest_version() -> u8 {
989 1
990}
991
992fn default_auto_start() -> bool {
993 true
994}
995
996fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
1002where
1003 D: serde::Deserializer<'de>,
1004{
1005 #[derive(Deserialize)]
1006 #[serde(untagged)]
1007 enum BoolOrStr {
1008 Bool(bool),
1009 Str(String),
1010 }
1011 Ok(match BoolOrStr::deserialize(deserializer)? {
1012 BoolOrStr::Bool(b) => b,
1013 BoolOrStr::Str(s) => matches!(
1014 s.trim().to_ascii_lowercase().as_str(),
1015 "true" | "eager" | "active" | "auto" | "on" | "1"
1016 ),
1017 })
1018}
1019
1020fn default_ui_path() -> String {
1021 "dist".to_string()
1022}
1023
1024fn default_app_type_native() -> AppType {
1025 AppType::Native
1026}
1027
1028impl AppManifest {
1029 pub fn resolved_requires(&self) -> Result<Vec<String>, String> {
1033 let top = normalized_requirements(&self.requires)?;
1034 let nested = normalized_requirements(&self.capabilities.requires)?;
1035 if !top.is_empty()
1036 && !nested.is_empty()
1037 && top.iter().cloned().collect::<BTreeSet<_>>()
1038 != nested.iter().cloned().collect::<BTreeSet<_>>()
1039 {
1040 return Err("top-level 'requires' conflicts with 'capabilities.requires'".to_string());
1041 }
1042 Ok(if !top.is_empty() { top } else { nested })
1043 }
1044
1045 pub fn effective_hot_reload(&self) -> HotReloadKind {
1048 self.hot_reload
1049 .unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
1050 }
1051
1052 pub fn effective_entrypoint(&self) -> &str {
1058 if let Some(ref ep) = self.entrypoint {
1059 ep.as_str()
1060 } else {
1061 match self.app_type {
1062 AppType::Native => "app.so",
1063 AppType::Bun => "dist/index.js",
1064 AppType::Standalone => "",
1065 AppType::PlatformRuntime => "bun",
1066 AppType::ManagedV1 => "llmc-generated-app",
1067 AppType::Burger => "dist/index.js",
1068 AppType::UiOnly => "",
1069 }
1070 }
1071 }
1072
1073 pub fn has_capability_providers(&self) -> bool {
1076 !self.provides.is_empty() || !self.capabilities.provides.is_empty()
1077 }
1078
1079 pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
1093 let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
1094 for raw in &self.capabilities.provides {
1095 let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
1096 if name.is_empty() {
1097 continue;
1098 }
1099 out.entry(name).or_insert(ProvidedCapability {
1100 description: String::new(),
1101 schema: None,
1102 });
1103 }
1104 out
1105 }
1106
1107 fn validate_schedules(&self) -> Result<(), String> {
1117 if self.schedules.is_empty() {
1118 return Ok(());
1119 }
1120 let provided = self.resolved_capability_provides();
1121 let mut seen: HashSet<&str> = HashSet::new();
1122 for schedule in &self.schedules {
1123 validate_schedule_id(&schedule.id)?;
1124 if !seen.insert(schedule.id.as_str()) {
1125 return Err(format!("duplicate schedule id '{}'", schedule.id));
1126 }
1127 validate_cron_shape(&schedule.cron)
1128 .map_err(|e| format!("schedule '{}': {}", schedule.id, e))?;
1129 if schedule.capability.trim().is_empty() {
1130 return Err(format!(
1131 "schedule '{}': capability must not be blank",
1132 schedule.id
1133 ));
1134 }
1135 if !provided.contains_key(schedule.capability.trim()) {
1136 return Err(format!(
1137 "schedule '{}' names capability '{}', which this app does not provide \
1138 (a schedule may only dispatch a capability declared in this manifest's \
1139 'provides')",
1140 schedule.id, schedule.capability
1141 ));
1142 }
1143 if let Some(payload) = &schedule.payload {
1144 if !payload.is_object() {
1145 return Err(format!(
1146 "schedule '{}': payload must be a JSON object",
1147 schedule.id
1148 ));
1149 }
1150 }
1151 }
1152 Ok(())
1153 }
1154
1155 pub fn validate(&self) -> Result<(), String> {
1160 self.validate_with_socket_path_policy(false)
1161 }
1162
1163 pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
1168 if self.manifest_version == 2 && self.abi.is_none() {
1170 return Err("manifest_version 2 requires an 'abi' field".to_string());
1171 }
1172
1173 validate_app_name(&self.name)?;
1176 self.resolved_requires()?;
1177
1178 if let Some(ref ep) = self.entrypoint {
1180 validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
1181 }
1182 if !self.ui_path.is_empty() && self.ui_path != "dist" {
1184 validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
1185 }
1186
1187 if let Some(ui) = &self.ui {
1188 validate_app_ui(&self.name, ui)?;
1189 }
1190
1191 if let Some(ref hp) = self.homepage {
1193 if !hp.starts_with("https://") && !hp.starts_with("http://") {
1194 return Err(format!(
1195 "homepage '{}' must use https:// or http:// scheme",
1196 hp
1197 ));
1198 }
1199 }
1200
1201 match self.app_type {
1208 AppType::Standalone => {
1209 if self.has_capability_providers() {
1210 let cfg = self.standalone.as_ref().ok_or_else(|| {
1211 "standalone apps that declare 'provides' require a \
1212 'standalone.socket_path' field"
1213 .to_string()
1214 })?;
1215 validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
1216 }
1217 }
1218 AppType::Native
1219 | AppType::Bun
1220 | AppType::PlatformRuntime
1221 | AppType::ManagedV1
1222 | AppType::Burger
1223 | AppType::UiOnly => {
1224 if self.standalone.is_some() {
1225 return Err(format!(
1226 "'standalone' block is only valid when app_type == 'standalone' \
1227 (found app_type='{}')",
1228 self.app_type
1229 ));
1230 }
1231 }
1232 }
1233
1234 if self.app_type == AppType::UiOnly {
1235 validate_ui_only(self)?;
1236 }
1237
1238 if let Some(resources) = &self.resources {
1239 if resources.memory_mb == Some(0) {
1240 return Err("resources.memory_mb must be at least 1".to_string());
1241 }
1242 if let Some(deadline) = resources.callback_deadline_ms {
1243 if !(1..=MAX_CALLBACK_DEADLINE_MS).contains(&deadline) {
1244 return Err(format!(
1245 "resources.callback_deadline_ms must be between 1 and {MAX_CALLBACK_DEADLINE_MS} (found {deadline})"
1246 ));
1247 }
1248 }
1249 }
1250
1251 self.validate_schedules()?;
1252
1253 if self.app_type == AppType::PlatformRuntime
1254 && !self.resolved_capability_provides().is_empty()
1255 {
1256 return Err(
1257 "platform-runtime packages cannot provide runtime capabilities".to_string(),
1258 );
1259 }
1260
1261 Ok(())
1262 }
1263
1264 pub fn from_json(json: &str) -> Result<Self, String> {
1266 Self::from_json_with_socket_path_policy(json, false)
1267 }
1268
1269 pub fn from_json_with_socket_path_policy(
1271 json: &str,
1272 allow_non_run: bool,
1273 ) -> Result<Self, String> {
1274 let mut manifest: Self =
1275 serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
1276 if serde_json::from_str::<serde_json::Value>(json).is_ok_and(|raw| declares_ui_only(&raw)) {
1277 manifest.app_type = AppType::UiOnly;
1278 }
1279 if manifest.ui.is_some() {
1280 manifest.has_ui = true;
1281 }
1282 manifest.validate_with_socket_path_policy(allow_non_run)?;
1283 Ok(manifest)
1284 }
1285}
1286
1287fn normalized_requirements(values: &[String]) -> Result<Vec<String>, String> {
1288 let mut seen = HashSet::new();
1289 let mut resolved = Vec::new();
1290 for value in values {
1291 let requirement = value.trim();
1292 if requirement.is_empty() {
1293 return Err("capability requirements must not be blank".to_string());
1294 }
1295 if seen.insert(requirement.to_string()) {
1296 resolved.push(requirement.to_string());
1297 }
1298 }
1299 Ok(resolved)
1300}
1301
1302fn validate_ui_only(manifest: &AppManifest) -> Result<(), String> {
1304 match &manifest.ui {
1305 Some(ui) if ui.kind == AppUiKind::Stage => {}
1306 _ => return Err("ui-only apps must declare a ui block of kind \"stage\"".to_string()),
1307 }
1308 match ui_only_process_declaration(manifest) {
1309 Some(what) => Err(format!(
1310 "ui-only apps have no backend process and must not declare {what}"
1311 )),
1312 None => Ok(()),
1313 }
1314}
1315
1316pub fn ui_only_process_declaration(manifest: &AppManifest) -> Option<&'static str> {
1321 if manifest.entrypoint.is_some() {
1322 Some("'entrypoint'")
1323 } else if manifest.has_capability_providers() {
1324 Some("capability providers ('provides' / 'capabilities.provides')")
1325 } else if manifest.tcp.is_some() {
1326 Some("a 'tcp' block")
1327 } else if manifest.resources.is_some() {
1328 Some("a 'resources' block")
1329 } else if manifest.standalone.is_some() {
1330 Some("a 'standalone' block")
1331 } else if !manifest.subscribes.is_empty() {
1332 Some("event subscriptions ('subscribes')")
1333 } else if manifest
1334 .depends_on
1335 .as_ref()
1336 .is_some_and(|deps| !deps.is_empty())
1337 {
1338 Some("dependencies ('depends_on')")
1339 } else {
1340 None
1341 }
1342}
1343
1344pub fn declares_ui_only(manifest: &serde_json::Value) -> bool {
1357 manifest.as_object().is_some_and(|object| {
1358 let derived = object.get("ui").is_some_and(serde_json::Value::is_object)
1359 && !object.contains_key("app_type")
1360 && !object.contains_key("entrypoint");
1361 derived || object.get("app_type").and_then(serde_json::Value::as_str) == Some("ui-only")
1362 })
1363}
1364
1365fn validate_app_ui(app_name: &str, ui: &AppUiManifest) -> Result<(), String> {
1366 if ui.ui_api != 1 && ui.ui_api != 2 {
1367 return Err(format!(
1368 "ui.ui_api {} is unsupported; only versions 1 and 2 are supported",
1369 ui.ui_api
1370 ));
1371 }
1372 if ui.title.trim().is_empty() {
1373 return Err("ui.title must not be blank".to_string());
1374 }
1375 ui.requires.resolved()?;
1376 validate_manifest_path(&ui.entry).map_err(|error| format!("ui.entry invalid: {error}"))?;
1377 if let Some(icon) = &ui.icon {
1378 validate_manifest_path(icon).map_err(|error| format!("ui.icon invalid: {error}"))?;
1379 }
1380 if let Some(nav) = &ui.nav {
1381 if ui.kind == AppUiKind::Widget {
1382 return Err("widget ui must omit nav metadata".to_string());
1383 }
1384 if nav.section.trim().is_empty() {
1385 return Err("ui.nav.section must not be blank".to_string());
1386 }
1387 }
1388 if let Some(data) = &ui.data {
1392 validate_app_data(app_name, data, &ui.requires.resolved()?)?;
1393 }
1394
1395 let mut composed = HashSet::new();
1396 for name in &ui.composes {
1397 validate_app_name(name).map_err(|error| format!("ui.composes entry invalid: {error}"))?;
1398 if name == app_name {
1399 return Err("ui.composes must not contain the app itself".to_string());
1400 }
1401 if !composed.insert(name) {
1402 return Err(format!("ui.composes contains duplicate app '{name}'"));
1403 }
1404 }
1405
1406 let mut surface_ids = HashSet::new();
1407 for surface in &ui.surfaces {
1408 let id = surface.id.trim();
1409 if id.is_empty() {
1410 return Err("ui.surfaces entry id must not be blank".to_string());
1411 }
1412 if !surface_ids.insert(id.to_string()) {
1413 return Err(format!("ui.surfaces contains duplicate id '{id}'"));
1414 }
1415 if !KNOWN_SURFACE_SLOTS.contains(&surface.slot.as_str()) {
1416 return Err(format!(
1417 "ui.surfaces entry '{id}' requests unknown slot '{}'; known slots: {}",
1418 surface.slot,
1419 KNOWN_SURFACE_SLOTS.join(", ")
1420 ));
1421 }
1422 if surface.title.trim().is_empty() {
1423 return Err(format!("ui.surfaces entry '{id}' title must not be blank"));
1424 }
1425 validate_manifest_path(&surface.entry)
1426 .map_err(|error| format!("ui.surfaces entry '{id}' entry invalid: {error}"))?;
1427 if !ui.integrity.contains_key(&surface.entry) {
1436 return Err(format!("ui.integrity must include surface '{id}' entry"));
1437 }
1438 surface
1439 .requires
1440 .resolved()
1441 .map_err(|error| format!("ui.surfaces entry '{id}' requires invalid: {error}"))?;
1442 }
1443
1444 for (path, digest) in &ui.integrity {
1445 validate_manifest_path(path)
1446 .map_err(|error| format!("ui.integrity path invalid: {error}"))?;
1447 if !is_lowercase_sha256(digest) {
1448 return Err(format!(
1449 "ui.integrity digest for '{path}' must be a lowercase 64-character SHA-256"
1450 ));
1451 }
1452 }
1453 if !ui.integrity.contains_key(&ui.entry) {
1454 return Err("ui.integrity must include the declared entry".to_string());
1455 }
1456 if let Some(icon) = &ui.icon {
1457 if !ui.integrity.contains_key(icon) {
1458 return Err("ui.integrity must include the declared icon".to_string());
1459 }
1460 }
1461 Ok(())
1462}
1463
1464fn validate_app_data(
1465 app_name: &str,
1466 data: &AppDataManifest,
1467 resolved_requires: &[String],
1468) -> Result<(), String> {
1469 validate_app_data_namespace(&data.namespace)?;
1470 validate_app_data_namespace_owner(app_name, &data.namespace)?;
1471 validate_app_data_sync_policy(&data.sync)?;
1472 if data.queries.is_empty() && data.offline != AppDataOfflinePolicy::OnlineOnly {
1473 return Err("ui.data.queries must declare at least one query for last-known data".to_string());
1474 }
1475
1476 let requires: BTreeSet<&str> = resolved_requires.iter().map(String::as_str).collect();
1477 let mut names = BTreeSet::new();
1478 for query in &data.queries {
1479 validate_namespaced_data_name(&query.name, &data.namespace)
1480 .map_err(|error| format!("ui.data query '{}' invalid: {error}", query.name))?;
1481 validate_capability_name(&query.capability).map_err(|error| {
1482 format!(
1483 "ui.data query '{}' capability '{}' invalid: {error}",
1484 query.name, query.capability
1485 )
1486 })?;
1487 if !requires.contains(query.capability.as_str()) {
1488 return Err(format!(
1489 "ui.data query '{}' capability '{}' must be declared in requires",
1490 query.name, query.capability
1491 ));
1492 }
1493 if !names.insert(query.name.as_str()) {
1494 return Err(format!("ui.data contains duplicate query '{}'", query.name));
1495 }
1496 }
1497
1498 for stream in &data.streams {
1499 validate_namespaced_data_name(&stream.name, &data.namespace)
1500 .map_err(|error| format!("ui.data stream '{}' invalid: {error}", stream.name))?;
1501 if !names.insert(stream.name.as_str()) {
1502 return Err(format!(
1503 "ui.data contains duplicate declaration '{}'",
1504 stream.name
1505 ));
1506 }
1507 }
1508
1509 Ok(())
1510}
1511
1512fn validate_app_data_namespace(namespace: &str) -> Result<(), String> {
1513 if !is_safe_name_segment(namespace) {
1514 return Err(format!(
1515 "ui.data namespace '{}' must match [a-z][a-z0-9-]*",
1516 namespace
1517 ));
1518 }
1519 if matches!(
1520 namespace,
1521 "core" | "internal" | "node" | "platform" | "system"
1522 ) {
1523 return Err(format!("ui.data namespace '{namespace}' is reserved"));
1524 }
1525 Ok(())
1526}
1527
1528pub fn validate_app_data_namespace_owner(app_name: &str, namespace: &str) -> Result<(), String> {
1539 if namespace != app_name {
1540 return Err(format!(
1541 "ui.data namespace '{namespace}' must equal the app name '{app_name}' — the Client \
1542 Node PWA refuses a stage whose data namespace it does not own; rename the namespace \
1543 to '{app_name}' and its query and stream names to '{app_name}.<name>.v<N>'"
1544 ));
1545 }
1546 Ok(())
1547}
1548
1549fn validate_app_data_sync_policy(sync: &AppDataSyncPolicy) -> Result<(), String> {
1550 validate_optional_range("cursor_ttl_secs", sync.cursor_ttl_secs, 60, 86_400)?;
1551 validate_optional_range(
1552 "full_refresh_interval_secs",
1553 sync.full_refresh_interval_secs,
1554 60,
1555 604_800,
1556 )?;
1557 validate_optional_range("retention_secs", sync.retention_secs, 300, 31_536_000)?;
1558 if sync.kind == AppDataSyncKind::Snapshot && sync.cursor_ttl_secs.is_some() {
1559 return Err("ui.data.sync cursor_ttl_secs is only valid for cursor sync".to_string());
1560 }
1561 Ok(())
1562}
1563
1564fn validate_optional_range(
1565 field: &str,
1566 value: Option<u32>,
1567 min: u32,
1568 max: u32,
1569) -> Result<(), String> {
1570 if let Some(value) = value {
1571 if value < min || value > max {
1572 return Err(format!(
1573 "ui.data.sync {field} must be between {min} and {max} seconds"
1574 ));
1575 }
1576 }
1577 Ok(())
1578}
1579
1580fn validate_namespaced_data_name(name: &str, namespace: &str) -> Result<(), String> {
1581 validate_capability_name(name)?;
1582 let Some(rest) = name
1583 .strip_prefix(namespace)
1584 .and_then(|suffix| suffix.strip_prefix('.'))
1585 else {
1586 return Err(format!("name must use namespace '{namespace}'"));
1587 };
1588 if rest.is_empty() {
1589 return Err("name must include a value after its namespace".to_string());
1590 }
1591 if !has_version_suffix(name) {
1592 return Err("name must end with a .vN version suffix".to_string());
1593 }
1594 Ok(())
1595}
1596
1597fn validate_capability_name(name: &str) -> Result<(), String> {
1598 if name.is_empty() {
1599 return Err("name must not be empty".to_string());
1600 }
1601 if name.contains('/') || name.contains("..") {
1602 return Err("name must not contain path separators or traversal".to_string());
1603 }
1604 if !name.split('.').all(is_safe_declaration_segment) {
1605 return Err("name must contain only lowercase dot-separated segments".to_string());
1606 }
1607 Ok(())
1608}
1609
1610fn validate_ui_requirement_name(name: &str, allow_wildcard: bool) -> Result<(), String> {
1611 if allow_wildcard && name.ends_with(".*") {
1612 return validate_capability_name(&name[..name.len() - 2]);
1613 }
1614 validate_capability_name(name)
1615}
1616
1617fn has_version_suffix(name: &str) -> bool {
1618 let Some(version) = name.rsplit('.').next() else {
1619 return false;
1620 };
1621 let Some(digits) = version.strip_prefix('v') else {
1622 return false;
1623 };
1624 !digits.is_empty()
1625 && !digits.starts_with('0')
1626 && digits.bytes().all(|byte| byte.is_ascii_digit())
1627}
1628
1629fn is_safe_name_segment(segment: &str) -> bool {
1630 if segment.is_empty() {
1631 return false;
1632 }
1633 let mut chars = segment.chars();
1634 let Some(first) = chars.next() else {
1635 return false;
1636 };
1637 first.is_ascii_lowercase()
1638 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
1639}
1640
1641fn is_safe_declaration_segment(segment: &str) -> bool {
1654 let mut chars = segment.chars();
1655 let Some(first) = chars.next() else {
1656 return false;
1657 };
1658 first.is_ascii_lowercase()
1659 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
1660}
1661
1662fn is_lowercase_sha256(value: &str) -> bool {
1663 value.len() == 64
1664 && value
1665 .bytes()
1666 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1667}
1668
1669pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
1678 validate_standalone_socket_path_with_policy(path, false)
1679}
1680
1681pub fn validate_standalone_socket_path_with_policy(
1683 path: &std::path::Path,
1684 allow_non_run: bool,
1685) -> Result<(), String> {
1686 if !path.is_absolute() {
1687 return Err(format!(
1688 "standalone.socket_path '{}' must be absolute",
1689 path.display()
1690 ));
1691 }
1692 if !allow_non_run && !path.starts_with("/run/") {
1693 return Err(format!(
1694 "standalone.socket_path '{}' must live under /run/",
1695 path.display()
1696 ));
1697 }
1698 if path
1699 .components()
1700 .any(|c| matches!(c, std::path::Component::ParentDir))
1701 {
1702 return Err(format!(
1703 "standalone.socket_path '{}' must not contain '..' segments",
1704 path.display()
1705 ));
1706 }
1707 Ok(())
1708}
1709
1710fn validate_app_name(name: &str) -> Result<(), String> {
1714 let (publisher, app) = if let Some(slash) = name.find('/') {
1715 let (p, rest) = name.split_at(slash);
1716 (Some(p), &rest[1..])
1717 } else {
1718 (None, name)
1719 };
1720
1721 let valid_segment = |s: &str| -> bool {
1722 if s.is_empty() {
1723 return false;
1724 }
1725 let mut chars = s.chars();
1726 let first = chars.next().unwrap();
1727 if !first.is_ascii_lowercase() {
1728 return false;
1729 }
1730 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1731 };
1732
1733 if let Some(pub_name) = publisher {
1734 if !valid_segment(pub_name) {
1735 return Err(format!(
1736 "publisher segment '{}' must match [a-z][a-z0-9-]*",
1737 pub_name
1738 ));
1739 }
1740 }
1741
1742 if !valid_segment(app) {
1743 return Err(format!(
1744 "app name segment '{}' must match [a-z][a-z0-9-]*",
1745 app
1746 ));
1747 }
1748
1749 Ok(())
1750}
1751
1752#[cfg(test)]
1755mod tests {
1756 use super::*;
1757
1758 fn parse_ok(json: &str) -> AppManifest {
1759 AppManifest::from_json(json).expect("should parse")
1760 }
1761
1762 fn parse_err(json: &str) -> String {
1763 AppManifest::from_json(json).expect_err("should fail")
1764 }
1765
1766 const SCHEDULED_APP: &str = r#"{
1769 "name":"network","version":"1.0.0","app_type":"bun","auto_start":"lazy",
1770 "provides":{"network.ledger.poll":{"description":"Record connections"}},
1771 "schedules":[{"id":"ledger-poll","cron":"0 */15 * * * *",
1772 "capability":"network.ledger.poll"}]
1773 }"#;
1774
1775 #[test]
1776 fn a_manifest_with_no_schedules_block_parses_to_an_empty_list() {
1777 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1780 assert!(m.schedules.is_empty());
1781 }
1782
1783 #[test]
1784 fn a_schedule_is_parsed_whole() {
1785 let m = parse_ok(SCHEDULED_APP);
1786 assert_eq!(m.schedules.len(), 1);
1787 let s = &m.schedules[0];
1788 assert_eq!(s.id, "ledger-poll");
1789 assert_eq!(s.cron, "0 */15 * * * *");
1790 assert_eq!(s.capability, "network.ledger.poll");
1791 assert_eq!(s.effective_payload(), serde_json::json!({}));
1794 }
1795
1796 #[test]
1797 fn a_schedule_may_only_dispatch_a_capability_this_app_provides() {
1798 let err = parse_err(
1801 r#"{"name":"network","version":"1.0.0","app_type":"bun",
1802 "provides":{"network.ledger.poll":{"description":"x"}},
1803 "schedules":[{"id":"drain","cron":"0 0 * * * *",
1804 "capability":"core.lightning.send_payment"}]}"#,
1805 );
1806 assert!(err.contains("core.lightning.send_payment"), "{err}");
1807 assert!(err.contains("does not provide"), "{err}");
1808 }
1809
1810 #[test]
1811 fn a_v2_capabilities_provides_entry_also_satisfies_the_gate() {
1812 let m = parse_ok(
1815 r#"{"manifest_version":2,"abi":"v1","name":"network","version":"1.0.0",
1816 "app_type":"bun",
1817 "capabilities":{"provides":["network.ledger.poll"]},
1818 "schedules":[{"id":"ledger-poll","cron":"0 */15 * * * *",
1819 "capability":"network.ledger.poll"}]}"#,
1820 );
1821 assert_eq!(m.schedules.len(), 1);
1822 }
1823
1824 #[test]
1825 fn a_cron_expression_without_six_fields_is_refused() {
1826 let err = parse_err(
1828 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1829 "provides":{"n.poll":{"description":"x"}},
1830 "schedules":[{"id":"p","cron":"*/15 * * * *","capability":"n.poll"}]}"#,
1831 );
1832 assert!(err.contains("6 fields"), "{err}");
1833 }
1834
1835 #[test]
1836 fn a_cron_expression_with_junk_in_it_is_refused() {
1837 let err = parse_err(
1838 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1839 "provides":{"n.poll":{"description":"x"}},
1840 "schedules":[{"id":"p","cron":"0 0 2 * * $(whoami)","capability":"n.poll"}]}"#,
1841 );
1842 assert!(err.contains("disallowed character"), "{err}");
1843 }
1844
1845 #[test]
1846 fn two_schedules_cannot_share_an_id() {
1847 let err = parse_err(
1850 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1851 "provides":{"n.poll":{"description":"x"}},
1852 "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll"},
1853 {"id":"p","cron":"0 30 * * * *","capability":"n.poll"}]}"#,
1854 );
1855 assert!(err.contains("duplicate schedule id"), "{err}");
1856 }
1857
1858 #[test]
1859 fn a_schedule_id_must_be_a_safe_token() {
1860 let err = parse_err(
1861 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1862 "provides":{"n.poll":{"description":"x"}},
1863 "schedules":[{"id":"../escape","cron":"0 0 * * * *","capability":"n.poll"}]}"#,
1864 );
1865 assert!(err.contains("schedule id"), "{err}");
1866 }
1867
1868 #[test]
1869 fn a_non_object_payload_is_refused() {
1870 let err = parse_err(
1871 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1872 "provides":{"n.poll":{"description":"x"}},
1873 "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll",
1874 "payload":"not-an-object"}]}"#,
1875 );
1876 assert!(err.contains("JSON object"), "{err}");
1877 }
1878
1879 #[test]
1880 fn a_declared_payload_survives_the_round_trip() {
1881 let m = parse_ok(
1882 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1883 "provides":{"n.poll":{"description":"x"}},
1884 "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll",
1885 "payload":{"depth":2}}]}"#,
1886 );
1887 assert_eq!(m.schedules[0].effective_payload(), serde_json::json!({"depth": 2}));
1888 let round_tripped: AppManifest =
1889 serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
1890 assert_eq!(round_tripped.schedules, m.schedules);
1891 }
1892
1893 #[test]
1894 fn an_empty_schedules_list_is_omitted_from_the_serialized_form() {
1895 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1897 let json = serde_json::to_string(&m).unwrap();
1898 assert!(!json.contains("schedules"), "{json}");
1899 }
1900
1901 #[test]
1904 fn v1_minimal_native() {
1905 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1906 assert_eq!(m.manifest_version, 1);
1907 assert_eq!(m.app_type, AppType::Native);
1908 assert!(m.abi.is_none());
1909 }
1910
1911 #[test]
1912 fn v1_minimal_bun() {
1913 let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
1914 assert_eq!(m.app_type, AppType::Bun);
1915 assert_eq!(m.effective_entrypoint(), "dist/index.js");
1916 }
1917
1918 #[test]
1919 fn v1_no_manifest_version_field_defaults_to_1() {
1920 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1921 assert_eq!(m.manifest_version, 1);
1922 }
1923
1924 #[test]
1925 fn v1_all_optional_fields_missing() {
1926 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1927 assert!(!m.critical);
1928 assert!(m.auto_start);
1929 assert!(!m.has_ui);
1930 assert_eq!(m.ui_path, "dist");
1931 assert!(m.permissions.is_empty());
1932 assert!(m.optional_permissions.is_empty());
1933 assert!(m.governor.is_none());
1936 assert!(m.subscribes.is_empty());
1937 }
1938
1939 #[test]
1940 fn v1_with_permissions_and_provides() {
1941 let json = r#"{
1942 "name": "example",
1943 "version": "1.0.0",
1944 "app_type": "bun",
1945 "permissions": ["core.storage.kv"],
1946 "optional_permissions": ["core.notifications.create"],
1947 "provides": {
1948 "core.example.run": { "description": "Run example job" }
1949 }
1950 }"#;
1951 let m = parse_ok(json);
1952 assert_eq!(m.permissions, vec!["core.storage.kv"]);
1953 assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
1954 assert!(m.provides.contains_key("core.example.run"));
1955 }
1956
1957 #[test]
1960 fn v2_minimal_native() {
1961 let json = r#"{
1962 "manifest_version": 2,
1963 "name": "cron",
1964 "version": "1.0.0",
1965 "app_type": "native",
1966 "abi": "v1",
1967 "entrypoint": "app.so",
1968 "hot_reload": "experimental"
1969 }"#;
1970 let m = parse_ok(json);
1971 assert_eq!(m.manifest_version, 2);
1972 assert_eq!(m.abi, Some(AbiVersion::V1));
1973 assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
1974 assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
1975 }
1976
1977 #[test]
1978 fn v2_minimal_bun_with_capabilities() {
1979 let json = r#"{
1980 "manifest_version": 2,
1981 "name": "example-fullstack",
1982 "version": "1.0.0",
1983 "app_type": "bun",
1984 "abi": "v1",
1985 "entrypoint": "dist/index.js",
1986 "hot_reload": "supported",
1987 "has_ui": true,
1988 "ui_path": "ui/dist",
1989 "capabilities": {
1990 "requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
1991 "provides": []
1992 },
1993 "governor": { "terminable": false, "min_idle_secs": 300 },
1994 "subscribes": ["core.chat.message.received"]
1995 }"#;
1996 let m = parse_ok(json);
1997 assert_eq!(m.manifest_version, 2);
1998 assert_eq!(m.capabilities.requires.len(), 2);
1999 let governor = m.governor.expect("governor block should parse");
2001 assert_eq!(governor.terminable, Some(false));
2002 assert_eq!(governor.min_idle_secs, Some(300));
2003 assert_eq!(m.subscribes, vec!["core.chat.message.received"]);
2004 }
2005
2006 #[test]
2007 fn stage_contract_fixture_parses_with_normalized_requirements() {
2008 let json = include_str!(
2009 "../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
2010 );
2011 let m = parse_ok(json);
2012 assert!(m.has_ui);
2013 assert_eq!(m.resolved_requires().unwrap(), vec!["core.metrics.latest"]);
2014 let ui = m.ui.expect("fixture should declare ui");
2015 assert_eq!(ui.kind, AppUiKind::Stage);
2016 assert_eq!(ui.entry, "ui/main.js");
2017 assert_eq!(ui.nav.unwrap().order, 10);
2018 }
2019
2020 #[test]
2021 fn omitted_ui_keeps_legacy_flags_without_fabricating_a_stage() {
2022 let m = parse_ok(
2023 r#"{"name":"legacy","version":"1.0.0","app_type":"bun","has_ui":true,"ui_path":"ui/dist"}"#,
2024 );
2025 assert!(m.has_ui);
2026 assert_eq!(m.ui_path, "ui/dist");
2027 assert!(m.ui.is_none());
2028 }
2029
2030 #[test]
2031 fn ui_requirements_are_typed_serialized_and_separate_from_backend_requires() {
2032 let manifest = parse_ok(
2033 r#"{
2034 "name":"ui-contract","version":"1.0.0","app_type":"bun",
2035 "requires":["core.cron.register"],
2036 "ui":{
2037 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
2038 "requires":{
2039 "capabilities":["ui.snapshot.v1"],
2040 "queries":["ui.query.v1"],
2041 "streams":["ui.event.v1"]
2042 },
2043 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
2044 }
2045 }"#,
2046 );
2047
2048 assert_eq!(
2049 manifest.resolved_requires().unwrap(),
2050 vec!["core.cron.register"]
2051 );
2052 let ui = manifest.ui.as_ref().expect("ui requirements should parse");
2053 assert_eq!(ui.requires.capabilities, vec!["ui.snapshot.v1"]);
2054 assert_eq!(ui.requires.queries, vec!["ui.query.v1"]);
2055 assert_eq!(ui.requires.streams, vec!["ui.event.v1"]);
2056 assert_eq!(
2057 ui.requires.resolved().unwrap(),
2058 vec!["ui.snapshot.v1", "ui.query.v1", "ui.event.v1"]
2059 );
2060 let serialized = serde_json::to_value(ui).unwrap();
2061 assert_eq!(
2062 serialized["requires"]["queries"],
2063 serde_json::json!(["ui.query.v1"])
2064 );
2065 }
2066
2067 #[test]
2068 fn ui_requirements_reject_blank_entries() {
2069 let error = parse_err(
2070 r#"{
2071 "name":"ui-contract","version":"1.0.0","app_type":"bun",
2072 "ui":{
2073 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
2074 "requires":{"capabilities":[""],"queries":[],"streams":[]},
2075 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
2076 }
2077 }"#,
2078 );
2079 assert!(error.contains("ui.requires entries must not be blank"));
2080 }
2081
2082 #[test]
2083 fn ui_data_namespace_stays_hyphen_only_when_declarations_allow_underscore() {
2084 assert!(validate_app_data_namespace("obs-viewer").is_ok());
2091
2092 let error = validate_app_data_namespace("obs_viewer")
2093 .expect_err("underscore must not be admitted into a storage namespace");
2094 assert!(
2095 error.contains("must match [a-z][a-z0-9-]*"),
2096 "unexpected error: {error}"
2097 );
2098 }
2099
2100 #[test]
2101 fn ui_data_query_capability_does_not_fall_back_to_backend_requires() {
2102 let error = parse_err(
2103 r#"{
2104 "name":"ui-data","version":"1.0.0","app_type":"bun",
2105 "requires":["ui.snapshot.v1"],
2106 "ui":{
2107 "kind":"stage","entry":"ui/main.js","title":"UI data","ui_api":1,
2108 "requires":{"capabilities":[],"queries":[],"streams":[]},
2109 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
2110 "data":{
2111 "namespace":"ui-data","offline":"last-known","sync":{"kind":"cursor"},
2112 "queries":[{"name":"ui-data.snapshot.v1","capability":"ui.snapshot.v1","kind":"snapshot"}],
2113 "streams":[]
2114 }
2115 }
2116 }"#,
2117 );
2118 assert!(error.contains("must be declared in requires"));
2119 }
2120
2121 #[test]
2126 fn ui_data_namespace_must_equal_the_app_name() {
2127 let manifest = |namespace: &str| {
2128 format!(
2129 r#"{{
2130 "name":"burger-runtime","version":"0.2.0",
2131 "ui":{{
2132 "kind":"stage","entry":"ui/dist/main.js","title":"Burger","ui_api":1,
2133 "requires":{{"capabilities":["core.runtime.burger_snapshot"]}},
2134 "integrity":{{"ui/dist/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},
2135 "data":{{
2136 "namespace":"{namespace}","offline":"online-only","sync":"snapshot",
2137 "queries":[{{"name":"{namespace}.snapshot.v1","capability":"core.runtime.burger_snapshot","kind":"snapshot"}}],
2138 "streams":[{{"name":"{namespace}.metrics.v1","kind":"events"}}]
2139 }}
2140 }}
2141 }}"#
2142 )
2143 };
2144
2145 let error = parse_err(&manifest("burger"));
2146 assert!(
2147 error.contains("ui.data namespace 'burger' must equal the app name 'burger-runtime'"),
2148 "unexpected error: {error}"
2149 );
2150
2151 let accepted = parse_ok(&manifest("burger-runtime"));
2152 let data = accepted.ui.as_ref().and_then(|ui| ui.data.as_ref());
2153 assert_eq!(
2154 data.map(|data| data.namespace.as_str()),
2155 Some("burger-runtime")
2156 );
2157 }
2158
2159 #[test]
2160 fn top_level_and_nested_requires_must_resolve_to_the_same_set() {
2161 let accepted = parse_ok(
2162 r#"{
2163 "name":"aliases","version":"1.0.0","app_type":"bun",
2164 "requires":["core.chat.read","core.chat.read","core.chat.send"],
2165 "capabilities":{"requires":["core.chat.send","core.chat.read"]}
2166 }"#,
2167 );
2168 assert_eq!(
2169 accepted.resolved_requires().unwrap(),
2170 vec!["core.chat.read", "core.chat.send"]
2171 );
2172
2173 let err = parse_err(
2174 r#"{
2175 "name":"aliases","version":"1.0.0","app_type":"bun",
2176 "requires":["core.chat.read"],
2177 "capabilities":{"requires":["core.wallet.pay"]}
2178 }"#,
2179 );
2180 assert!(err.contains("conflicts"), "unexpected error: {err}");
2181 }
2182
2183 #[test]
2184 fn stage_and_widget_ui_kinds_have_distinct_navigation_rules() {
2185 let base = |ui: &str| {
2186 format!(r#"{{"name":"stage","version":"1.0.0","app_type":"bun","ui":{ui}}}"#)
2187 };
2188 let widget = base(
2189 r#"{"kind":"widget","entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2190 );
2191 assert_eq!(
2192 parse_ok(&widget).ui.expect("widget ui").kind,
2193 AppUiKind::Widget
2194 );
2195 let widget_nav = base(
2196 r#"{"kind":"widget","entry":"ui/main.js","title":"Widget","nav":{"section":"default","order":1},"ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2197 );
2198 assert!(parse_err(&widget_nav).contains("must omit nav"));
2199 let api = base(
2200 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":2,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2201 );
2202 assert_eq!(parse_ok(&api).ui.expect("v2 stage ui").ui_api, 2);
2203 let unsupported_api = base(
2204 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":3,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2205 );
2206 assert!(parse_err(&unsupported_api).contains("ui_api"));
2207 let path = base(
2208 r#"{"kind":"stage","entry":"../main.js","title":"Stage","ui_api":1,"integrity":{"../main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2209 );
2210 assert!(parse_err(&path).contains("entry"));
2211 }
2212
2213 #[test]
2214 fn stage_requires_integrity_for_entry_and_icon() {
2215 let missing_entry = r#"{
2216 "name":"stage","version":"1.0.0","app_type":"bun",
2217 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{}}
2218 }"#;
2219 assert!(parse_err(missing_entry).contains("entry"));
2220 let missing_icon = r#"{
2221 "name":"stage","version":"1.0.0","app_type":"bun",
2222 "ui":{"entry":"ui/main.js","icon":"ui/icon.svg","title":"Stage","ui_api":1,
2223 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
2224 }"#;
2225 assert!(parse_err(missing_icon).contains("icon"));
2226 let uppercase = r#"{
2227 "name":"stage","version":"1.0.0","app_type":"bun",
2228 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
2229 "integrity":{"ui/main.js":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}
2230 }"#;
2231 assert!(parse_err(uppercase).contains("lowercase"));
2232 }
2233
2234 #[test]
2235 fn stage_composes_rejects_self_duplicate_and_unsafe_names() {
2236 let manifest = |composes: &str| {
2237 format!(
2238 r#"{{
2239 "name":"stage","version":"1.0.0","app_type":"bun",
2240 "ui":{{"entry":"ui/main.js","title":"Stage","ui_api":1,
2241 "composes":{composes},
2242 "integrity":{{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}}
2243 }}"#
2244 )
2245 };
2246 assert!(parse_err(&manifest(r#"["stage"]"#)).contains("itself"));
2247 assert!(parse_err(&manifest(r#"["chat","chat"]"#)).contains("duplicate"));
2248 assert!(parse_err(&manifest(r#"["../chat"]"#)).contains("invalid"));
2249 }
2250
2251 fn manifest_with_ui() -> AppManifest {
2263 parse_ok(
2264 r#"{
2265 "name":"stage","version":"1.0.0","app_type":"bun",
2266 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
2267 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2268 "ui/dist/surfaces/chip.js":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}
2269 }"#,
2270 )
2271 }
2272
2273 fn surface(id: &str, slot: &str) -> AppUiSurface {
2274 AppUiSurface {
2275 id: id.to_string(),
2276 slot: slot.to_string(),
2277 entry: "ui/dist/surfaces/chip.js".to_string(),
2278 title: "Chip".to_string(),
2279 order: 10,
2280 requires: AppUiRequirements {
2281 capabilities: vec!["wallet.balance.get".to_string()],
2282 ..Default::default()
2283 },
2284 }
2285 }
2286
2287 #[test]
2288 fn manifest_without_surfaces_still_parses() {
2289 let manifest = manifest_with_ui();
2290 assert!(manifest.ui.as_ref().unwrap().surfaces.is_empty());
2291 assert!(manifest.validate().is_ok());
2292 }
2293
2294 #[test]
2295 fn surface_in_a_known_slot_is_accepted() {
2296 let mut manifest = manifest_with_ui();
2297 manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "status-rail")];
2298 assert!(manifest.validate().is_ok());
2299 }
2300
2301 #[test]
2302 fn surface_in_an_unknown_slot_is_rejected() {
2303 let mut manifest = manifest_with_ui();
2304 manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "menu-bar")];
2305 let error = manifest.validate().unwrap_err();
2306 assert!(error.contains("menu-bar"), "unexpected error: {error}");
2307 }
2308
2309 #[test]
2310 fn duplicate_surface_ids_are_rejected() {
2311 let mut manifest = manifest_with_ui();
2312 manifest.ui.as_mut().unwrap().surfaces = vec![
2313 surface("chip", "status-rail"),
2314 surface("chip", "status-rail"),
2315 ];
2316 let error = manifest.validate().unwrap_err();
2317 assert!(error.contains("duplicate"), "unexpected error: {error}");
2318 }
2319
2320 #[test]
2321 fn surface_with_a_blank_id_is_rejected() {
2322 let mut manifest = manifest_with_ui();
2323 manifest.ui.as_mut().unwrap().surfaces = vec![surface(" ", "status-rail")];
2324 assert!(manifest.validate().is_err());
2325 }
2326
2327 #[test]
2328 fn surface_with_an_unsafe_entry_path_is_rejected() {
2329 let mut manifest = manifest_with_ui();
2330 let mut bad = surface("chip", "status-rail");
2331 bad.entry = "../../etc/passwd".to_string();
2332 manifest.ui.as_mut().unwrap().surfaces = vec![bad];
2333 assert!(manifest.validate().is_err());
2334 }
2335
2336 #[test]
2337 fn surface_entry_missing_from_integrity_is_rejected() {
2338 let mut manifest = manifest_with_ui();
2342 let mut unpinned = surface("chip", "status-rail");
2343 unpinned.entry = "ui/dist/surfaces/typo.js".to_string();
2344 manifest.ui.as_mut().unwrap().surfaces = vec![unpinned];
2345 let error = manifest.validate().unwrap_err();
2346 assert!(
2347 error.contains("ui.integrity must include surface 'chip' entry"),
2348 "unexpected error: {error}"
2349 );
2350 }
2351
2352 #[test]
2353 fn surface_with_a_blank_required_capability_is_rejected() {
2354 let mut manifest = manifest_with_ui();
2355 let mut bad = surface("chip", "status-rail");
2356 bad.requires.capabilities = vec![" ".to_string()];
2357 manifest.ui.as_mut().unwrap().surfaces = vec![bad];
2358 assert!(manifest.validate().is_err());
2359 }
2360
2361 #[test]
2362 fn v2_missing_abi_is_error() {
2363 let json = r#"{
2364 "manifest_version": 2,
2365 "name": "example",
2366 "version": "1.0.0",
2367 "app_type": "bun"
2368 }"#;
2369 let err = parse_err(json);
2370 assert!(err.contains("abi"), "expected abi error, got: {}", err);
2371 }
2372
2373 #[test]
2376 fn publisher_prefixed_name_accepted() {
2377 let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
2378 assert_eq!(m.name, "alice/weather");
2379 }
2380
2381 #[test]
2382 fn double_slash_name_rejected() {
2383 let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
2384 assert!(!err.is_empty());
2385 }
2386
2387 #[test]
2390 fn name_starting_with_digit_rejected() {
2391 let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
2392 assert!(!err.is_empty());
2393 }
2394
2395 #[test]
2396 fn name_with_uppercase_rejected() {
2397 let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
2398 assert!(!err.is_empty());
2399 }
2400
2401 #[test]
2402 fn empty_name_rejected() {
2403 let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
2404 assert!(!err.is_empty());
2405 }
2406
2407 #[test]
2410 fn path_traversal_double_dot_rejected() {
2411 let json = r#"{
2412 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2413 "app_type": "bun", "abi": "v1",
2414 "entrypoint": "../etc/passwd"
2415 }"#;
2416 let err = parse_err(json);
2417 assert!(err.contains(".."), "expected traversal error, got: {}", err);
2418 }
2419
2420 #[test]
2421 fn path_traversal_encoded_dot_not_decoded() {
2422 let json = r#"{
2424 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2425 "app_type": "bun", "abi": "v1",
2426 "entrypoint": "foo/../bar"
2427 }"#;
2428 let err = parse_err(json);
2429 assert!(!err.is_empty(), "should have failed: {}", err);
2430 }
2431
2432 #[test]
2433 fn absolute_path_rejected() {
2434 let json = r#"{
2435 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2436 "app_type": "bun", "abi": "v1",
2437 "entrypoint": "/usr/bin/sh"
2438 }"#;
2439 let err = parse_err(json);
2440 assert!(
2441 err.contains("absolute"),
2442 "expected absolute error, got: {}",
2443 err
2444 );
2445 }
2446
2447 #[test]
2448 fn shell_metachar_in_path_rejected() {
2449 let json = r#"{
2450 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2451 "app_type": "bun", "abi": "v1",
2452 "entrypoint": "dist/index.js;rm -rf /"
2453 }"#;
2454 let err = parse_err(json);
2455 assert!(!err.is_empty());
2456 }
2457
2458 #[test]
2459 fn valid_nested_path_accepted() {
2460 let json = r#"{
2461 "manifest_version": 2, "name": "my-app", "version": "1.0.0",
2462 "app_type": "bun", "abi": "v1",
2463 "entrypoint": "dist/index.js",
2464 "ui_path": "ui/dist"
2465 }"#;
2466 parse_ok(json);
2467 }
2468
2469 #[test]
2472 fn homepage_https_accepted() {
2473 let json = r#"{
2474 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2475 "homepage": "https://example.com"
2476 }"#;
2477 parse_ok(json);
2478 }
2479
2480 #[test]
2481 fn homepage_javascript_scheme_rejected() {
2482 let json = r#"{
2483 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2484 "homepage": "javascript:alert(1)"
2485 }"#;
2486 let err = parse_err(json);
2487 assert!(
2488 err.contains("scheme"),
2489 "expected scheme error, got: {}",
2490 err
2491 );
2492 }
2493
2494 #[test]
2495 fn homepage_file_scheme_rejected() {
2496 let json = r#"{
2497 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2498 "homepage": "file:///etc/passwd"
2499 }"#;
2500 let err = parse_err(json);
2501 assert!(!err.is_empty());
2502 }
2503
2504 #[test]
2507 fn effective_entrypoint_native_default() {
2508 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2509 assert_eq!(m.effective_entrypoint(), "app.so");
2510 }
2511
2512 #[test]
2513 fn effective_entrypoint_bun_default() {
2514 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2515 assert_eq!(m.effective_entrypoint(), "dist/index.js");
2516 }
2517
2518 #[test]
2519 fn effective_hot_reload_native_default_is_experimental() {
2520 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2521 assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
2522 }
2523
2524 #[test]
2525 fn effective_hot_reload_bun_default_is_supported() {
2526 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2527 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
2528 }
2529
2530 #[test]
2531 fn hot_reload_unsupported_explicit() {
2532 let json = r#"{
2533 "manifest_version": 2, "name": "myapp", "version": "1.0.0",
2534 "app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
2535 }"#;
2536 let m = parse_ok(json);
2537 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
2538 }
2539
2540 #[test]
2543 fn validate_path_simple_valid() {
2544 assert!(validate_manifest_path("dist/index.js").is_ok());
2545 assert!(validate_manifest_path("app.so").is_ok());
2546 assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
2547 assert!(validate_manifest_path("build_output/main").is_ok());
2548 }
2549
2550 #[test]
2551 fn validate_path_empty_rejected() {
2552 assert!(validate_manifest_path("").is_err());
2553 }
2554
2555 #[test]
2556 fn validate_path_absolute_rejected() {
2557 assert!(validate_manifest_path("/usr/bin/sh").is_err());
2558 }
2559
2560 #[test]
2561 fn validate_path_double_dot_segment_rejected() {
2562 assert!(validate_manifest_path("foo/../bar").is_err());
2563 assert!(validate_manifest_path("../etc/passwd").is_err());
2564 }
2565
2566 #[test]
2567 fn validate_path_leading_dot_rejected() {
2568 assert!(validate_manifest_path(".hidden").is_err());
2569 }
2570
2571 #[test]
2572 fn validate_path_null_byte_rejected() {
2573 let path = "foo\0bar";
2575 assert!(validate_manifest_path(path).is_err());
2576 }
2577
2578 #[test]
2579 fn standalone_socket_path_development_override_is_explicit_and_pure() {
2580 let path = std::path::Path::new("/tmp/node-app/example.sock");
2581 assert!(validate_standalone_socket_path(path).is_err());
2582 assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
2583 assert!(validate_standalone_socket_path_with_policy(
2584 std::path::Path::new("/tmp/node-app/../escape.sock"),
2585 true,
2586 )
2587 .is_err());
2588 }
2589
2590 #[test]
2593 fn manifest_capabilities_defaults_to_empty() {
2594 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2595 assert!(m.capabilities.requires.is_empty());
2596 assert!(m.capabilities.provides.is_empty());
2597 }
2598
2599 #[test]
2602 fn resolved_capability_provides_v1_only() {
2603 let json = r#"{
2604 "name": "example", "version": "1.0.0", "app_type": "bun",
2605 "provides": { "core.example.run": { "description": "Run example job" } }
2606 }"#;
2607 let m = parse_ok(json);
2608 let out = m.resolved_capability_provides();
2609 assert_eq!(out.len(), 1);
2610 assert_eq!(
2611 out.get("core.example.run").unwrap().description,
2612 "Run example job"
2613 );
2614 }
2615
2616 #[test]
2617 fn resolved_capability_provides_v2_names_get_blank_declaration() {
2618 let json = r#"{
2619 "manifest_version": 2, "name": "example", "version": "1.0.0",
2620 "app_type": "bun", "abi": "v1",
2621 "capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
2622 }"#;
2623 let m = parse_ok(json);
2624 let out = m.resolved_capability_provides();
2625 assert_eq!(out.len(), 2);
2626 assert_eq!(out.get("core.example.run").unwrap().description, "");
2627 assert!(out.get("core.example.run").unwrap().schema.is_none());
2628 assert!(out.contains_key("core.example.other"));
2630 assert!(!out.contains_key("core.example.other:extra"));
2631 }
2632
2633 #[test]
2634 fn resolved_capability_provides_v1_wins_on_conflict() {
2635 let json = r#"{
2636 "manifest_version": 2, "name": "example", "version": "1.0.0",
2637 "app_type": "bun", "abi": "v1",
2638 "provides": { "core.example.run": { "description": "v1 wins" } },
2639 "capabilities": { "requires": [], "provides": ["core.example.run"] }
2640 }"#;
2641 let m = parse_ok(json);
2642 let out = m.resolved_capability_provides();
2643 assert_eq!(out.len(), 1);
2644 assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
2645 }
2646
2647 #[test]
2648 fn resolved_capability_provides_blank_v2_name_skipped() {
2649 let json = r#"{
2650 "manifest_version": 2, "name": "example", "version": "1.0.0",
2651 "app_type": "bun", "abi": "v1",
2652 "capabilities": { "requires": [], "provides": [" ", "core.example.run"] }
2653 }"#;
2654 let m = parse_ok(json);
2655 let out = m.resolved_capability_provides();
2656 assert_eq!(out.len(), 1);
2657 assert!(out.contains_key("core.example.run"));
2658 }
2659
2660 #[test]
2661 fn resolved_capability_provides_empty_manifest_yields_empty_map() {
2662 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2663 assert!(m.resolved_capability_provides().is_empty());
2664 }
2665
2666 #[test]
2669 fn abi_v1_is_supported() {
2670 assert!(AbiVersion::V1.is_supported());
2671 }
2672
2673 #[test]
2676 fn app_tier_display() {
2677 assert_eq!(AppTier::FirstParty.to_string(), "first_party");
2678 assert_eq!(AppTier::Optional.to_string(), "optional");
2679 assert_eq!(AppTier::Development.to_string(), "development");
2680 }
2681
2682 #[test]
2683 fn app_tier_serde_roundtrip() {
2684 for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
2686 let json = serde_json::to_string(&tier).unwrap();
2687 let back: AppTier = serde_json::from_str(&json).unwrap();
2688 assert_eq!(
2689 tier, back,
2690 "roundtrip failed for {:?}: serialized as {}",
2691 tier, json
2692 );
2693 }
2694 assert_eq!(
2695 serde_json::to_string(&AppTier::Development).unwrap(),
2696 "\"development\""
2697 );
2698 }
2699
2700 #[test]
2703 fn app_type_display() {
2704 assert_eq!(AppType::Native.to_string(), "native");
2705 assert_eq!(AppType::Bun.to_string(), "bun");
2706 assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
2707 }
2708
2709 #[test]
2710 fn platform_runtime_is_a_supported_packaging_type() {
2711 let manifest: AppManifest = serde_json::from_value(serde_json::json!({
2712 "manifest_version": 2,
2713 "abi": "v1",
2714 "name": "bun-runtime",
2715 "version": "1.0.0",
2716 "app_type": "platform-runtime",
2717 "entrypoint": "bun"
2718 }))
2719 .expect("platform runtime manifest should parse");
2720
2721 assert_eq!(manifest.app_type, AppType::PlatformRuntime);
2722 assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
2723 }
2724
2725 #[test]
2728 fn governor_manifest_memory_budget_defaults_to_none() {
2729 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2730 assert_eq!(parsed.memory_budget_kb, None);
2731 }
2732
2733 #[test]
2734 fn governor_manifest_parses_declared_memory_budget() {
2735 let parsed: GovernorManifest =
2736 serde_json::from_str(r#"{"memory_budget_kb": 40960}"#).unwrap();
2737 assert_eq!(parsed.memory_budget_kb, Some(40_960));
2738 }
2739
2740 #[test]
2743 fn latency_class_parses_and_defaults_none() {
2744 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2745 assert_eq!(parsed.latency_class, None);
2746 }
2747
2748 #[test]
2749 fn latency_class_parses_declared_interactive() {
2750 let json = r#"{
2751 "name": "example",
2752 "version": "1.0.0",
2753 "app_type": "bun",
2754 "governor": {"latency_class": "interactive"}
2755 }"#;
2756 let m = parse_ok(json);
2757 let governor = m.governor.expect("governor block should parse");
2758 assert_eq!(governor.latency_class, Some(LatencyClass::Interactive));
2759 }
2760
2761 #[test]
2762 fn latency_class_parses_declared_background() {
2763 let parsed: GovernorManifest =
2764 serde_json::from_str(r#"{"latency_class": "background"}"#).unwrap();
2765 assert_eq!(parsed.latency_class, Some(LatencyClass::Background));
2766 }
2767
2768 #[test]
2769 fn latency_class_invalid_value_is_parse_error() {
2770 let result: Result<GovernorManifest, _> =
2771 serde_json::from_str(r#"{"latency_class": "urgent"}"#);
2772 assert!(result.is_err(), "unknown latency_class value must fail to parse");
2773 }
2774
2775 #[test]
2776 fn latency_class_as_str_and_from_str_roundtrip() {
2777 for class in [LatencyClass::Interactive, LatencyClass::Background] {
2778 let s = class.as_str();
2779 assert_eq!(LatencyClass::from_str(s), Ok(class));
2780 }
2781 assert!(LatencyClass::from_str("urgent").is_err());
2782 }
2783
2784 fn ui_only_manifest() -> serde_json::Value {
2787 serde_json::json!({
2788 "manifest_version": 2, "abi": "v1", "name": "burger-runtime", "version": "1.0.0",
2789 "auto_start": false, "has_ui": true, "ui_path": "ui/dist",
2790 "ui": {
2791 "kind": "stage", "entry": "ui/dist/main.js", "title": "Burger", "icon": "ui/dist/icon.svg",
2792 "nav": { "section": "system", "order": 90 }, "ui_api": 1,
2793 "requires": {
2794 "capabilities": ["core.runtime.burger_snapshot", "core.runtime.burger_logs"],
2795 "queries": [], "streams": ["app.burger_metrics"]
2796 },
2797 "data": {
2798 "namespace": "burger-runtime", "offline": "online-only", "sync": "snapshot",
2799 "queries": [{ "name": "burger-runtime.snapshot.v1", "capability": "core.runtime.burger_snapshot", "kind": "snapshot" }],
2800 "streams": [{ "name": "burger-runtime.metrics.v1", "kind": "events" }]
2801 },
2802 "integrity": { "ui/dist/main.js": "a".repeat(64), "ui/dist/icon.svg": "b".repeat(64) }
2803 }
2804 })
2805 }
2806
2807 fn ui_only_with(key: &str, value: serde_json::Value) -> String {
2808 let mut manifest = ui_only_manifest();
2809 manifest[key] = value;
2810 manifest.to_string()
2811 }
2812
2813 #[test]
2814 fn a_stage_manifest_without_app_type_or_entrypoint_is_ui_only() {
2815 let m = parse_ok(&ui_only_manifest().to_string());
2816 assert_eq!(m.app_type, AppType::UiOnly);
2817 assert_eq!(m.app_type.as_str(), "ui-only");
2818 assert_eq!(m.effective_entrypoint(), "", "no backend, no entrypoint");
2819 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
2820 assert!(m.has_ui);
2821 let ui = m.ui.as_ref().expect("a ui block");
2822 assert_eq!(ui.kind, AppUiKind::Stage);
2823 let data = ui.data.as_ref().expect("the F5 ui.data block validates");
2824 assert_eq!(data.namespace, "burger-runtime");
2825 assert_eq!(data.offline, AppDataOfflinePolicy::OnlineOnly);
2826 assert_eq!(data.queries[0].capability, "core.runtime.burger_snapshot");
2827 assert_eq!(data.streams[0].name, "burger-runtime.metrics.v1");
2828 }
2829
2830 #[test]
2831 fn a_ui_only_manifest_round_trips_through_its_serialized_form() {
2832 let m = parse_ok(&ui_only_manifest().to_string());
2833 let wire = serde_json::to_string(&m).unwrap();
2834 assert!(wire.contains(r#""app_type":"ui-only""#), "{wire}");
2835 assert_eq!(parse_ok(&wire).app_type, AppType::UiOnly);
2836 }
2837
2838 #[test]
2839 fn a_ui_only_manifest_refuses_everything_that_implies_a_process() {
2840 for (key, value) in [
2841 (
2842 "provides",
2843 serde_json::json!({ "burger.runtime.peek": { "description": "x" } }),
2844 ),
2845 (
2846 "capabilities",
2847 serde_json::json!({ "provides": ["burger.runtime.peek"] }),
2848 ),
2849 ("tcp", serde_json::json!({ "preferred_port": 7010 })),
2850 ("resources", serde_json::json!({ "memory_mb": 16 })),
2851 (
2852 "standalone",
2853 serde_json::json!({ "socket_path": "/run/node/x.sock" }),
2854 ),
2855 ("subscribes", serde_json::json!(["system.network.changed"])),
2856 ("depends_on", serde_json::json!(["cron"])),
2857 ] {
2858 let err = parse_err(&ui_only_with(key, value));
2859 assert!(err.contains("ui-only"), "{key}: {err}");
2860 }
2861 let mut explicit_with_entry = ui_only_manifest();
2862 explicit_with_entry["app_type"] = serde_json::json!("ui-only");
2863 explicit_with_entry["entrypoint"] = serde_json::json!("dist/index.js");
2864 assert!(parse_err(&explicit_with_entry.to_string()).contains("'entrypoint'"));
2865 }
2866
2867 #[test]
2868 fn a_ui_only_manifest_must_declare_a_stage() {
2869 let mut widget = ui_only_manifest();
2870 widget["ui"]["kind"] = serde_json::json!("widget");
2871 let widget_ui = widget["ui"].as_object_mut().unwrap();
2872 widget_ui.remove("nav");
2873 widget_ui.remove("data");
2876 assert!(parse_err(&widget.to_string()).contains(r#"kind "stage""#));
2877 let no_ui = r#"{"manifest_version":2,"abi":"v1","name":"x","version":"1.0.0","app_type":"ui-only"}"#;
2878 assert!(parse_err(no_ui).contains(r#"kind "stage""#));
2879 }
2880
2881 #[test]
2882 fn declaring_app_type_or_entrypoint_keeps_the_existing_defaults() {
2883 assert_eq!(
2884 parse_ok(r#"{"name":"cron","version":"1.0.0"}"#).app_type,
2885 AppType::Native
2886 );
2887 let mut with_entry = ui_only_manifest();
2888 with_entry["entrypoint"] = serde_json::json!("app.so");
2889 assert_eq!(parse_ok(&with_entry.to_string()).app_type, AppType::Native);
2890 let mut bun = ui_only_manifest();
2891 bun["app_type"] = serde_json::json!("bun");
2892 assert_eq!(parse_ok(&bun.to_string()).app_type, AppType::Bun);
2893 }
2894
2895 #[test]
2896 fn declares_ui_only_accepts_the_derived_and_the_explicit_form_only() {
2897 assert!(declares_ui_only(&ui_only_manifest()));
2898 assert!(declares_ui_only(
2899 &serde_json::json!({ "app_type": "ui-only" })
2900 ));
2901 assert!(!declares_ui_only(
2902 &serde_json::json!({ "app_type": "bun", "ui": {} })
2903 ));
2904 assert!(!declares_ui_only(
2905 &serde_json::json!({ "entrypoint": "app.so", "ui": {} })
2906 ));
2907 assert!(!declares_ui_only(&serde_json::json!({ "name": "cron" })));
2908 assert!(!declares_ui_only(&serde_json::json!("ui-only")));
2909 }
2910}
2911
2912#[cfg(test)]
2913mod shared_app_data_corpus_tests {
2914 use super::*;
2915
2916 #[test]
2921 fn shared_app_data_corpus_matches_the_host_validator() {
2922 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/app-data");
2923 let mut checked = 0;
2924 for entry in std::fs::read_dir(&dir).expect("fixture directory must exist") {
2925 let path = entry.expect("readable entry").path();
2926 if path.extension().and_then(|e| e.to_str()) != Some("json") {
2927 continue;
2928 }
2929 let fixture: serde_json::Value =
2930 serde_json::from_str(&std::fs::read_to_string(&path).expect("readable fixture"))
2931 .expect("valid fixture json");
2932 let name = path
2933 .file_name()
2934 .and_then(|n| n.to_str())
2935 .unwrap_or("?")
2936 .to_string();
2937 let manifest = serde_json::json!({
2941 "manifest_version": 2, "abi": "v1", "name": "notes", "version": "0.1.0",
2942 "app_type": "bun", "entrypoint": "dist/index.js",
2943 "ui": fixture["ui"],
2944 });
2945 let result = AppManifest::from_json(&manifest.to_string()).and_then(|m| m.validate());
2946 match fixture["expect"].as_str().expect("expect field") {
2947 "accept" => assert!(result.is_ok(), "{name} should be accepted: {result:?}"),
2948 "reject" => {
2949 let error = result.expect_err(&format!("{name} should be rejected"));
2950 let reason = fixture["reason"]
2951 .as_str()
2952 .expect("reject fixtures need a reason");
2953 assert!(
2954 error.contains(reason),
2955 "{name}: {error:?} should mention {reason:?}"
2956 );
2957 }
2958 other => panic!("{name}: unknown expect {other:?}"),
2959 }
2960 checked += 1;
2961 }
2962 assert!(checked >= 10, "expected the full corpus, walked {checked}");
2964 }
2965}
2966
2967#[cfg(test)]
2968mod burger_manifest_tests {
2969 use super::*;
2970
2971 #[test]
2972 fn burger_app_type_parses_with_bundle_entrypoint_default() {
2973 let m = AppManifest::from_json(r#"{"name":"did","version":"2.0.0","app_type":"burger"}"#)
2974 .expect("burger manifest parses");
2975 assert_eq!(m.app_type, AppType::Burger);
2976 assert_eq!(m.app_type.as_str(), "burger");
2977 assert_eq!(m.effective_entrypoint(), "dist/index.js");
2978 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
2979 assert!(m.resources.is_none());
2980 }
2981
2982 #[test]
2983 fn resources_memory_mb_is_carried() {
2984 let m = AppManifest::from_json(
2985 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"memory_mb":48}}"#,
2986 )
2987 .expect("resources block parses");
2988 assert_eq!(
2989 m.resources,
2990 Some(ResourcesManifest { memory_mb: Some(48), callback_deadline_ms: None })
2991 );
2992 }
2993
2994 #[test]
2995 fn resources_callback_deadline_ms_is_carried() {
2996 let m = AppManifest::from_json(
2997 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"callback_deadline_ms":750}}"#,
2998 )
2999 .expect("callback deadline parses");
3000 assert_eq!(
3001 m.resources,
3002 Some(ResourcesManifest { memory_mb: None, callback_deadline_ms: Some(750) })
3003 );
3004 let max = AppManifest::from_json(
3005 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"callback_deadline_ms":600000}}"#,
3006 )
3007 .expect("the maximum is inclusive");
3008 assert_eq!(
3009 max.resources.and_then(|r| r.callback_deadline_ms),
3010 Some(MAX_CALLBACK_DEADLINE_MS)
3011 );
3012 }
3013
3014 #[test]
3015 fn resources_callback_deadline_ms_out_of_range_is_rejected() {
3016 for bad in ["0", "600001"] {
3017 let json = format!(
3018 r#"{{"name":"did","version":"2.0.0","app_type":"burger","resources":{{"callback_deadline_ms":{bad}}}}}"#
3019 );
3020 let err = AppManifest::from_json(&json).expect_err("out-of-range callback deadline");
3021 assert!(err.contains("resources.callback_deadline_ms"), "{bad}: {err}");
3022 }
3023 }
3024
3025 #[test]
3026 fn resources_callback_deadline_ms_must_be_a_positive_integer() {
3027 for bad in ["-1", "1.5", "\"5000\""] {
3028 let json = format!(
3029 r#"{{"name":"did","version":"2.0.0","app_type":"burger","resources":{{"callback_deadline_ms":{bad}}}}}"#
3030 );
3031 assert!(
3032 AppManifest::from_json(&json).is_err(),
3033 "callback_deadline_ms={bad} must be rejected"
3034 );
3035 }
3036 }
3037
3038 #[test]
3039 fn resources_memory_mb_zero_is_rejected() {
3040 let err = AppManifest::from_json(
3041 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"memory_mb":0}}"#,
3042 )
3043 .expect_err("a zero memory limit is invalid");
3044 assert!(err.contains("resources.memory_mb"), "{err}");
3045 }
3046
3047 #[test]
3048 fn burger_manifest_rejects_a_standalone_block() {
3049 let err = AppManifest::from_json(
3050 r#"{"name":"did","version":"2.0.0","app_type":"burger","standalone":{"socket_path":"/run/node-app-did.sock"}}"#,
3051 )
3052 .expect_err("standalone block is only valid for standalone apps");
3053 assert!(err.contains("only valid when app_type == 'standalone'"), "{err}");
3054 }
3055}