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}
56
57impl AppType {
58 pub fn as_str(self) -> &'static str {
59 match self {
60 AppType::Native => "native",
61 AppType::Bun => "bun",
62 AppType::Standalone => "standalone",
63 AppType::PlatformRuntime => "platform-runtime",
64 AppType::ManagedV1 => "managed-v1",
65 }
66 }
67}
68
69impl std::fmt::Display for AppType {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 write!(f, "{}", self.as_str())
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum AppTier {
82 FirstParty,
86 Optional,
90 Development,
101}
102
103impl AppTier {
104 pub fn as_str(self) -> &'static str {
105 match self {
106 AppTier::FirstParty => "first_party",
107 AppTier::Optional => "optional",
108 AppTier::Development => "development",
109 }
110 }
111}
112
113impl std::fmt::Display for AppTier {
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 write!(f, "{}", self.as_str())
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "lowercase")]
125pub enum AbiVersion {
126 V1,
127}
128
129impl AbiVersion {
130 pub fn as_str(&self) -> &'static str {
131 match self {
132 AbiVersion::V1 => "v1",
133 }
134 }
135
136 pub fn is_supported(&self) -> bool {
138 matches!(self, AbiVersion::V1)
139 }
140}
141
142impl std::fmt::Display for AbiVersion {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 write!(f, "{}", self.as_str())
145 }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(rename_all = "snake_case")]
154pub enum HotReloadKind {
155 Supported,
157 Experimental,
167 Unsupported,
169}
170
171impl HotReloadKind {
172 pub fn default_for(app_type: AppType) -> Self {
173 match app_type {
174 AppType::Native => HotReloadKind::Experimental,
175 AppType::Bun => HotReloadKind::Supported,
176 AppType::Standalone => HotReloadKind::Unsupported,
179 AppType::PlatformRuntime => HotReloadKind::Unsupported,
180 AppType::ManagedV1 => HotReloadKind::Supported,
181 }
182 }
183}
184
185#[derive(Debug, Clone, Default, Serialize, Deserialize)]
192pub struct ManifestCapabilities {
193 #[serde(default)]
196 pub requires: Vec<String>,
197
198 #[serde(default)]
201 pub provides: Vec<String>,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, Default)]
206pub struct ProvidedScope {
207 pub scope: String,
208 pub description: String,
209 pub resource_pattern: String,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct EndpointPolicy {
215 pub method: String,
216 pub path: String,
217 pub required_permissions: Vec<String>,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct ProvidedCapability {
223 #[serde(default)]
224 pub description: String,
225 #[serde(default)]
226 pub schema: Option<serde_json::Value>,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct StandaloneConfig {
241 pub socket_path: std::path::PathBuf,
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(rename_all = "snake_case")]
247pub enum AppUiKind {
248 Stage,
249 Widget,
250}
251
252fn default_app_ui_kind() -> AppUiKind {
253 AppUiKind::Stage
254}
255
256fn default_nav_section() -> String {
257 "default".to_string()
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262pub struct AppUiNav {
263 #[serde(default = "default_nav_section")]
264 pub section: String,
265 #[serde(default)]
266 pub order: i32,
267}
268
269pub const KNOWN_SURFACE_SLOTS: &[&str] = &["status-rail"];
281
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290pub struct AppUiSurface {
291 pub id: String,
292 pub slot: String,
293 pub entry: String,
294 pub title: String,
295 #[serde(default)]
296 pub order: i32,
297 #[serde(default)]
298 pub requires: AppUiRequirements,
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
303#[serde(rename_all = "kebab-case")]
304pub enum AppDataOfflinePolicy {
305 LastKnown,
307 OnlineOnly,
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
313#[serde(rename_all = "kebab-case")]
314pub enum AppDataQueryKind {
315 Collection,
316 Detail,
317 Snapshot,
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
322#[serde(rename_all = "kebab-case")]
323pub enum AppDataStreamKind {
324 Changes,
325 Events,
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
330#[serde(rename_all = "kebab-case")]
331pub enum AppDataSyncKind {
332 Cursor,
333 Snapshot,
334}
335
336#[derive(Debug, Clone, PartialEq, Eq)]
338pub struct AppDataSyncPolicy {
339 pub kind: AppDataSyncKind,
340 pub cursor_ttl_secs: Option<u32>,
341 pub full_refresh_interval_secs: Option<u32>,
342 pub retention_secs: Option<u32>,
343}
344
345impl Serialize for AppDataSyncPolicy {
346 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
347 where
348 S: serde::Serializer,
349 {
350 use serde::ser::SerializeStruct;
351
352 if self.cursor_ttl_secs.is_none()
353 && self.full_refresh_interval_secs.is_none()
354 && self.retention_secs.is_none()
355 {
356 return self.kind.serialize(serializer);
357 }
358
359 let mut state = serializer.serialize_struct("AppDataSyncPolicy", 4)?;
360 state.serialize_field("kind", &self.kind)?;
361 if let Some(cursor_ttl_secs) = self.cursor_ttl_secs {
362 state.serialize_field("cursor_ttl_secs", &cursor_ttl_secs)?;
363 }
364 if let Some(full_refresh_interval_secs) = self.full_refresh_interval_secs {
365 state.serialize_field("full_refresh_interval_secs", &full_refresh_interval_secs)?;
366 }
367 if let Some(retention_secs) = self.retention_secs {
368 state.serialize_field("retention_secs", &retention_secs)?;
369 }
370 state.end()
371 }
372}
373
374impl<'de> Deserialize<'de> for AppDataSyncPolicy {
375 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
376 where
377 D: serde::Deserializer<'de>,
378 {
379 #[derive(Deserialize)]
380 #[serde(deny_unknown_fields)]
381 struct ObjectPolicy {
382 kind: AppDataSyncKind,
383 #[serde(default)]
384 cursor_ttl_secs: Option<u32>,
385 #[serde(default)]
386 full_refresh_interval_secs: Option<u32>,
387 #[serde(default)]
388 retention_secs: Option<u32>,
389 }
390
391 #[derive(Deserialize)]
392 #[serde(untagged)]
393 enum WirePolicy {
394 Kind(AppDataSyncKind),
395 Object(ObjectPolicy),
396 }
397
398 match WirePolicy::deserialize(deserializer)? {
399 WirePolicy::Kind(kind) => Ok(Self {
400 kind,
401 cursor_ttl_secs: None,
402 full_refresh_interval_secs: None,
403 retention_secs: None,
404 }),
405 WirePolicy::Object(policy) => Ok(Self {
406 kind: policy.kind,
407 cursor_ttl_secs: policy.cursor_ttl_secs,
408 full_refresh_interval_secs: policy.full_refresh_interval_secs,
409 retention_secs: policy.retention_secs,
410 }),
411 }
412 }
413}
414
415#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
417#[serde(deny_unknown_fields)]
418pub struct AppDataQueryDeclaration {
419 pub name: String,
420 pub capability: String,
421 pub kind: AppDataQueryKind,
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
426#[serde(deny_unknown_fields)]
427pub struct AppDataStreamDeclaration {
428 pub name: String,
429 pub kind: AppDataStreamKind,
430}
431
432#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
434#[serde(deny_unknown_fields)]
435pub struct AppDataManifest {
436 pub namespace: String,
437 pub offline: AppDataOfflinePolicy,
438 pub sync: AppDataSyncPolicy,
439 #[serde(default)]
440 pub queries: Vec<AppDataQueryDeclaration>,
441 #[serde(default)]
442 pub streams: Vec<AppDataStreamDeclaration>,
443}
444
445#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
450#[serde(deny_unknown_fields)]
451pub struct AppUiRequirements {
452 #[serde(default)]
453 pub capabilities: Vec<String>,
454 #[serde(default)]
455 pub queries: Vec<String>,
456 #[serde(default)]
457 pub streams: Vec<String>,
458}
459
460impl AppUiRequirements {
461 pub fn resolved(&self) -> Result<Vec<String>, String> {
465 let mut resolved = Vec::new();
466 let mut seen = HashSet::new();
467 for (values, allow_wildcard) in [
468 (&self.capabilities, true),
469 (&self.queries, false),
470 (&self.streams, false),
471 ] {
472 for value in values {
473 let requirement = value.trim();
474 if requirement.is_empty() {
475 return Err("ui.requires entries must not be blank".to_string());
476 }
477 validate_ui_requirement_name(requirement, allow_wildcard).map_err(|error| {
478 format!("ui.requires entry '{requirement}' invalid: {error}")
479 })?;
480 if seen.insert(requirement.to_string()) {
481 resolved.push(requirement.to_string());
482 }
483 }
484 }
485 Ok(resolved)
486 }
487}
488
489#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
491pub struct AppUiManifest {
492 #[serde(default = "default_app_ui_kind")]
493 pub kind: AppUiKind,
494 pub entry: String,
495 pub title: String,
496 #[serde(default)]
497 pub icon: Option<String>,
498 #[serde(default)]
499 pub nav: Option<AppUiNav>,
500 #[serde(default)]
501 pub composes: Vec<String>,
502 #[serde(default)]
504 pub surfaces: Vec<AppUiSurface>,
505 pub ui_api: u8,
506 #[serde(default)]
507 pub integrity: BTreeMap<String, String>,
508 #[serde(default)]
512 pub description: Option<String>,
513 #[serde(default)]
516 pub keywords: Vec<String>,
517 #[serde(default)]
520 pub requires: AppUiRequirements,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
522 pub data: Option<AppDataManifest>,
523}
524
525pub fn validate_manifest_path(path: &str) -> Result<(), String> {
537 if path.is_empty() {
538 return Err("path must not be empty".to_string());
539 }
540
541 if path.starts_with('/') {
543 return Err(format!(
544 "path '{}' must not be absolute (starts with /)",
545 path
546 ));
547 }
548
549 let first = path.chars().next().unwrap();
552 if !first.is_ascii_alphanumeric() && first != '_' {
553 return Err(format!(
554 "path '{}' must begin with an alphanumeric character or underscore",
555 path
556 ));
557 }
558 for ch in path.chars().skip(1) {
559 if !ch.is_ascii_alphanumeric() && !matches!(ch, '_' | '.' | '/' | '-') {
560 return Err(format!(
561 "path '{}' contains disallowed character '{}'",
562 path, ch
563 ));
564 }
565 }
566
567 for segment in path.split('/') {
569 if segment == ".." {
570 return Err(format!(
571 "path '{}' contains a '..' segment (path traversal rejected)",
572 path
573 ));
574 }
575 }
576
577 Ok(())
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize)]
588pub struct AppManifest {
589 #[serde(default = "default_manifest_version", rename = "manifest_version")]
591 pub manifest_version: u8,
592
593 pub name: String,
594 pub version: String,
595
596 #[serde(default = "default_app_type_native")]
597 pub app_type: AppType,
598
599 #[serde(default)]
600 pub description: String,
601
602 pub abi: Option<AbiVersion>,
605
606 pub entrypoint: Option<String>,
609
610 pub hot_reload: Option<HotReloadKind>,
613
614 #[serde(default)]
616 pub critical: bool,
617
618 #[serde(
619 default = "default_auto_start",
620 deserialize_with = "deserialize_auto_start"
621 )]
622 pub auto_start: bool,
623
624 #[serde(default)]
625 pub has_ui: bool,
626
627 #[serde(default = "default_ui_path")]
628 pub ui_path: String,
629
630 #[serde(default)]
631 pub permissions: Vec<String>,
632
633 #[serde(default)]
636 pub requires: Vec<String>,
637
638 #[serde(default)]
639 pub optional_permissions: Vec<String>,
640
641 #[serde(default)]
642 pub provides_scopes: Vec<ProvidedScope>,
643
644 #[serde(default)]
645 pub endpoint_policies: Vec<EndpointPolicy>,
646
647 #[serde(default)]
648 pub capability_scopes: HashMap<String, String>,
649
650 #[serde(default)]
651 pub provides: HashMap<String, ProvidedCapability>,
652
653 #[serde(default)]
655 pub capabilities: ManifestCapabilities,
656
657 #[serde(default, skip_serializing_if = "Option::is_none")]
660 pub ui: Option<AppUiManifest>,
661
662 #[serde(default)]
664 pub author: Option<String>,
665
666 #[serde(default)]
667 pub homepage: Option<String>,
668
669 #[serde(default)]
670 pub depends_on: Option<Vec<String>>,
671
672 #[serde(default)]
673 pub boot_priority: Option<u32>,
674
675 #[serde(default)]
679 pub governor: Option<GovernorManifest>,
680
681 #[serde(default)]
687 pub subscribes: Vec<String>,
688
689 #[serde(default)]
693 pub standalone: Option<StandaloneConfig>,
694
695 #[serde(default, skip_serializing_if = "Option::is_none")]
698 pub tcp: Option<TcpManifest>,
699}
700
701#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
707#[serde(rename_all = "lowercase")]
708pub enum LatencyClass {
709 Interactive,
712 Background,
715}
716
717impl LatencyClass {
718 pub fn as_str(&self) -> &'static str {
719 match self {
720 LatencyClass::Interactive => "interactive",
721 LatencyClass::Background => "background",
722 }
723 }
724
725 #[allow(clippy::should_implement_trait)]
726 pub fn from_str(s: &str) -> Result<Self, String> {
727 match s {
728 "interactive" => Ok(LatencyClass::Interactive),
729 "background" => Ok(LatencyClass::Background),
730 _ => Err(format!("Invalid LatencyClass: {}", s)),
731 }
732 }
733}
734
735impl std::fmt::Display for LatencyClass {
736 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
737 write!(f, "{}", self.as_str())
738 }
739}
740
741#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
744pub struct GovernorManifest {
745 #[serde(default)]
749 pub terminable: Option<bool>,
750
751 #[serde(default)]
755 pub min_idle_secs: Option<u64>,
756
757 #[serde(default)]
790 pub memory_budget_kb: Option<u64>,
791
792 #[serde(default)]
797 pub latency_class: Option<LatencyClass>,
798}
799
800#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
804pub struct TcpManifest {
805 #[serde(default, skip_serializing_if = "Option::is_none")]
809 pub preferred_port: Option<u16>,
810
811 #[serde(default, skip_serializing_if = "Option::is_none")]
818 pub direct_bind: Option<bool>,
819}
820
821fn default_manifest_version() -> u8 {
822 1
823}
824
825fn default_auto_start() -> bool {
826 true
827}
828
829fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
835where
836 D: serde::Deserializer<'de>,
837{
838 #[derive(Deserialize)]
839 #[serde(untagged)]
840 enum BoolOrStr {
841 Bool(bool),
842 Str(String),
843 }
844 Ok(match BoolOrStr::deserialize(deserializer)? {
845 BoolOrStr::Bool(b) => b,
846 BoolOrStr::Str(s) => matches!(
847 s.trim().to_ascii_lowercase().as_str(),
848 "true" | "eager" | "active" | "auto" | "on" | "1"
849 ),
850 })
851}
852
853fn default_ui_path() -> String {
854 "dist".to_string()
855}
856
857fn default_app_type_native() -> AppType {
858 AppType::Native
859}
860
861impl AppManifest {
862 pub fn resolved_requires(&self) -> Result<Vec<String>, String> {
866 let top = normalized_requirements(&self.requires)?;
867 let nested = normalized_requirements(&self.capabilities.requires)?;
868 if !top.is_empty()
869 && !nested.is_empty()
870 && top.iter().cloned().collect::<BTreeSet<_>>()
871 != nested.iter().cloned().collect::<BTreeSet<_>>()
872 {
873 return Err("top-level 'requires' conflicts with 'capabilities.requires'".to_string());
874 }
875 Ok(if !top.is_empty() { top } else { nested })
876 }
877
878 pub fn effective_hot_reload(&self) -> HotReloadKind {
881 self.hot_reload
882 .unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
883 }
884
885 pub fn effective_entrypoint(&self) -> &str {
890 if let Some(ref ep) = self.entrypoint {
891 ep.as_str()
892 } else {
893 match self.app_type {
894 AppType::Native => "app.so",
895 AppType::Bun => "dist/index.js",
896 AppType::Standalone => "",
897 AppType::PlatformRuntime => "bun",
898 AppType::ManagedV1 => "llmc-generated-app",
899 }
900 }
901 }
902
903 pub fn has_capability_providers(&self) -> bool {
906 !self.provides.is_empty() || !self.capabilities.provides.is_empty()
907 }
908
909 pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
923 let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
924 for raw in &self.capabilities.provides {
925 let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
926 if name.is_empty() {
927 continue;
928 }
929 out.entry(name).or_insert(ProvidedCapability {
930 description: String::new(),
931 schema: None,
932 });
933 }
934 out
935 }
936
937 pub fn validate(&self) -> Result<(), String> {
942 self.validate_with_socket_path_policy(false)
943 }
944
945 pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
950 if self.manifest_version == 2 && self.abi.is_none() {
952 return Err("manifest_version 2 requires an 'abi' field".to_string());
953 }
954
955 validate_app_name(&self.name)?;
958 self.resolved_requires()?;
959
960 if let Some(ref ep) = self.entrypoint {
962 validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
963 }
964 if !self.ui_path.is_empty() && self.ui_path != "dist" {
966 validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
967 }
968
969 if let Some(ui) = &self.ui {
970 validate_app_ui(&self.name, ui)?;
971 }
972
973 if let Some(ref hp) = self.homepage {
975 if !hp.starts_with("https://") && !hp.starts_with("http://") {
976 return Err(format!(
977 "homepage '{}' must use https:// or http:// scheme",
978 hp
979 ));
980 }
981 }
982
983 match self.app_type {
990 AppType::Standalone => {
991 if self.has_capability_providers() {
992 let cfg = self.standalone.as_ref().ok_or_else(|| {
993 "standalone apps that declare 'provides' require a \
994 'standalone.socket_path' field"
995 .to_string()
996 })?;
997 validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
998 }
999 }
1000 AppType::Native | AppType::Bun | AppType::PlatformRuntime | AppType::ManagedV1 => {
1001 if self.standalone.is_some() {
1002 return Err(format!(
1003 "'standalone' block is only valid when app_type == 'standalone' \
1004 (found app_type='{}')",
1005 self.app_type
1006 ));
1007 }
1008 }
1009 }
1010
1011 if self.app_type == AppType::PlatformRuntime
1012 && !self.resolved_capability_provides().is_empty()
1013 {
1014 return Err(
1015 "platform-runtime packages cannot provide runtime capabilities".to_string(),
1016 );
1017 }
1018
1019 Ok(())
1020 }
1021
1022 pub fn from_json(json: &str) -> Result<Self, String> {
1024 Self::from_json_with_socket_path_policy(json, false)
1025 }
1026
1027 pub fn from_json_with_socket_path_policy(
1029 json: &str,
1030 allow_non_run: bool,
1031 ) -> Result<Self, String> {
1032 let mut manifest: Self =
1033 serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
1034 if manifest.ui.is_some() {
1035 manifest.has_ui = true;
1036 }
1037 manifest.validate_with_socket_path_policy(allow_non_run)?;
1038 Ok(manifest)
1039 }
1040}
1041
1042fn normalized_requirements(values: &[String]) -> Result<Vec<String>, String> {
1043 let mut seen = HashSet::new();
1044 let mut resolved = Vec::new();
1045 for value in values {
1046 let requirement = value.trim();
1047 if requirement.is_empty() {
1048 return Err("capability requirements must not be blank".to_string());
1049 }
1050 if seen.insert(requirement.to_string()) {
1051 resolved.push(requirement.to_string());
1052 }
1053 }
1054 Ok(resolved)
1055}
1056
1057fn validate_app_ui(app_name: &str, ui: &AppUiManifest) -> Result<(), String> {
1058 if ui.ui_api != 1 && ui.ui_api != 2 {
1059 return Err(format!(
1060 "ui.ui_api {} is unsupported; only versions 1 and 2 are supported",
1061 ui.ui_api
1062 ));
1063 }
1064 if ui.title.trim().is_empty() {
1065 return Err("ui.title must not be blank".to_string());
1066 }
1067 ui.requires.resolved()?;
1068 validate_manifest_path(&ui.entry).map_err(|error| format!("ui.entry invalid: {error}"))?;
1069 if let Some(icon) = &ui.icon {
1070 validate_manifest_path(icon).map_err(|error| format!("ui.icon invalid: {error}"))?;
1071 }
1072 if let Some(nav) = &ui.nav {
1073 if ui.kind == AppUiKind::Widget {
1074 return Err("widget ui must omit nav metadata".to_string());
1075 }
1076 if nav.section.trim().is_empty() {
1077 return Err("ui.nav.section must not be blank".to_string());
1078 }
1079 }
1080 if let Some(data) = &ui.data {
1081 if ui.kind != AppUiKind::Stage {
1082 return Err("widget ui must omit app data declarations".to_string());
1083 }
1084 validate_app_data(data, &ui.requires.resolved()?)?;
1085 }
1086
1087 let mut composed = HashSet::new();
1088 for name in &ui.composes {
1089 validate_app_name(name).map_err(|error| format!("ui.composes entry invalid: {error}"))?;
1090 if name == app_name {
1091 return Err("ui.composes must not contain the app itself".to_string());
1092 }
1093 if !composed.insert(name) {
1094 return Err(format!("ui.composes contains duplicate app '{name}'"));
1095 }
1096 }
1097
1098 let mut surface_ids = HashSet::new();
1099 for surface in &ui.surfaces {
1100 let id = surface.id.trim();
1101 if id.is_empty() {
1102 return Err("ui.surfaces entry id must not be blank".to_string());
1103 }
1104 if !surface_ids.insert(id.to_string()) {
1105 return Err(format!("ui.surfaces contains duplicate id '{id}'"));
1106 }
1107 if !KNOWN_SURFACE_SLOTS.contains(&surface.slot.as_str()) {
1108 return Err(format!(
1109 "ui.surfaces entry '{id}' requests unknown slot '{}'; known slots: {}",
1110 surface.slot,
1111 KNOWN_SURFACE_SLOTS.join(", ")
1112 ));
1113 }
1114 if surface.title.trim().is_empty() {
1115 return Err(format!("ui.surfaces entry '{id}' title must not be blank"));
1116 }
1117 validate_manifest_path(&surface.entry)
1118 .map_err(|error| format!("ui.surfaces entry '{id}' entry invalid: {error}"))?;
1119 if !ui.integrity.contains_key(&surface.entry) {
1128 return Err(format!("ui.integrity must include surface '{id}' entry"));
1129 }
1130 surface
1131 .requires
1132 .resolved()
1133 .map_err(|error| format!("ui.surfaces entry '{id}' requires invalid: {error}"))?;
1134 }
1135
1136 for (path, digest) in &ui.integrity {
1137 validate_manifest_path(path)
1138 .map_err(|error| format!("ui.integrity path invalid: {error}"))?;
1139 if !is_lowercase_sha256(digest) {
1140 return Err(format!(
1141 "ui.integrity digest for '{path}' must be a lowercase 64-character SHA-256"
1142 ));
1143 }
1144 }
1145 if !ui.integrity.contains_key(&ui.entry) {
1146 return Err("ui.integrity must include the declared entry".to_string());
1147 }
1148 if let Some(icon) = &ui.icon {
1149 if !ui.integrity.contains_key(icon) {
1150 return Err("ui.integrity must include the declared icon".to_string());
1151 }
1152 }
1153 Ok(())
1154}
1155
1156fn validate_app_data(data: &AppDataManifest, resolved_requires: &[String]) -> Result<(), String> {
1157 validate_app_data_namespace(&data.namespace)?;
1158 validate_app_data_sync_policy(&data.sync)?;
1159 if data.queries.is_empty() {
1160 return Err("ui.data.queries must declare at least one query".to_string());
1161 }
1162
1163 let requires: BTreeSet<&str> = resolved_requires.iter().map(String::as_str).collect();
1164 let mut names = BTreeSet::new();
1165 for query in &data.queries {
1166 validate_namespaced_data_name(&query.name, &data.namespace)
1167 .map_err(|error| format!("ui.data query '{}' invalid: {error}", query.name))?;
1168 validate_capability_name(&query.capability).map_err(|error| {
1169 format!(
1170 "ui.data query '{}' capability '{}' invalid: {error}",
1171 query.name, query.capability
1172 )
1173 })?;
1174 if !requires.contains(query.capability.as_str()) {
1175 return Err(format!(
1176 "ui.data query '{}' capability '{}' must be declared in requires",
1177 query.name, query.capability
1178 ));
1179 }
1180 if !names.insert(query.name.as_str()) {
1181 return Err(format!("ui.data contains duplicate query '{}'", query.name));
1182 }
1183 }
1184
1185 for stream in &data.streams {
1186 validate_namespaced_data_name(&stream.name, &data.namespace)
1187 .map_err(|error| format!("ui.data stream '{}' invalid: {error}", stream.name))?;
1188 if !names.insert(stream.name.as_str()) {
1189 return Err(format!(
1190 "ui.data contains duplicate declaration '{}'",
1191 stream.name
1192 ));
1193 }
1194 }
1195
1196 Ok(())
1197}
1198
1199fn validate_app_data_namespace(namespace: &str) -> Result<(), String> {
1200 if !is_safe_name_segment(namespace) {
1201 return Err(format!(
1202 "ui.data namespace '{}' must match [a-z][a-z0-9-]*",
1203 namespace
1204 ));
1205 }
1206 if matches!(
1207 namespace,
1208 "core" | "internal" | "node" | "platform" | "system"
1209 ) {
1210 return Err(format!("ui.data namespace '{namespace}' is reserved"));
1211 }
1212 Ok(())
1213}
1214
1215fn validate_app_data_sync_policy(sync: &AppDataSyncPolicy) -> Result<(), String> {
1216 validate_optional_range("cursor_ttl_secs", sync.cursor_ttl_secs, 60, 86_400)?;
1217 validate_optional_range(
1218 "full_refresh_interval_secs",
1219 sync.full_refresh_interval_secs,
1220 60,
1221 604_800,
1222 )?;
1223 validate_optional_range("retention_secs", sync.retention_secs, 300, 31_536_000)?;
1224 if sync.kind == AppDataSyncKind::Snapshot && sync.cursor_ttl_secs.is_some() {
1225 return Err("ui.data.sync cursor_ttl_secs is only valid for cursor sync".to_string());
1226 }
1227 Ok(())
1228}
1229
1230fn validate_optional_range(
1231 field: &str,
1232 value: Option<u32>,
1233 min: u32,
1234 max: u32,
1235) -> Result<(), String> {
1236 if let Some(value) = value {
1237 if value < min || value > max {
1238 return Err(format!(
1239 "ui.data.sync {field} must be between {min} and {max} seconds"
1240 ));
1241 }
1242 }
1243 Ok(())
1244}
1245
1246fn validate_namespaced_data_name(name: &str, namespace: &str) -> Result<(), String> {
1247 validate_capability_name(name)?;
1248 let Some(rest) = name
1249 .strip_prefix(namespace)
1250 .and_then(|suffix| suffix.strip_prefix('.'))
1251 else {
1252 return Err(format!("name must use namespace '{namespace}'"));
1253 };
1254 if rest.is_empty() {
1255 return Err("name must include a value after its namespace".to_string());
1256 }
1257 if !has_version_suffix(name) {
1258 return Err("name must end with a .vN version suffix".to_string());
1259 }
1260 Ok(())
1261}
1262
1263fn validate_capability_name(name: &str) -> Result<(), String> {
1264 if name.is_empty() {
1265 return Err("name must not be empty".to_string());
1266 }
1267 if name.contains('/') || name.contains("..") {
1268 return Err("name must not contain path separators or traversal".to_string());
1269 }
1270 if !name.split('.').all(is_safe_declaration_segment) {
1271 return Err("name must contain only lowercase dot-separated segments".to_string());
1272 }
1273 Ok(())
1274}
1275
1276fn validate_ui_requirement_name(name: &str, allow_wildcard: bool) -> Result<(), String> {
1277 if allow_wildcard && name.ends_with(".*") {
1278 return validate_capability_name(&name[..name.len() - 2]);
1279 }
1280 validate_capability_name(name)
1281}
1282
1283fn has_version_suffix(name: &str) -> bool {
1284 let Some(version) = name.rsplit('.').next() else {
1285 return false;
1286 };
1287 let Some(digits) = version.strip_prefix('v') else {
1288 return false;
1289 };
1290 !digits.is_empty()
1291 && !digits.starts_with('0')
1292 && digits.bytes().all(|byte| byte.is_ascii_digit())
1293}
1294
1295fn is_safe_name_segment(segment: &str) -> bool {
1296 if segment.is_empty() {
1297 return false;
1298 }
1299 let mut chars = segment.chars();
1300 let Some(first) = chars.next() else {
1301 return false;
1302 };
1303 first.is_ascii_lowercase()
1304 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
1305}
1306
1307fn is_safe_declaration_segment(segment: &str) -> bool {
1320 let mut chars = segment.chars();
1321 let Some(first) = chars.next() else {
1322 return false;
1323 };
1324 first.is_ascii_lowercase()
1325 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
1326}
1327
1328fn is_lowercase_sha256(value: &str) -> bool {
1329 value.len() == 64
1330 && value
1331 .bytes()
1332 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1333}
1334
1335pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
1344 validate_standalone_socket_path_with_policy(path, false)
1345}
1346
1347pub fn validate_standalone_socket_path_with_policy(
1349 path: &std::path::Path,
1350 allow_non_run: bool,
1351) -> Result<(), String> {
1352 if !path.is_absolute() {
1353 return Err(format!(
1354 "standalone.socket_path '{}' must be absolute",
1355 path.display()
1356 ));
1357 }
1358 if !allow_non_run && !path.starts_with("/run/") {
1359 return Err(format!(
1360 "standalone.socket_path '{}' must live under /run/",
1361 path.display()
1362 ));
1363 }
1364 if path
1365 .components()
1366 .any(|c| matches!(c, std::path::Component::ParentDir))
1367 {
1368 return Err(format!(
1369 "standalone.socket_path '{}' must not contain '..' segments",
1370 path.display()
1371 ));
1372 }
1373 Ok(())
1374}
1375
1376fn validate_app_name(name: &str) -> Result<(), String> {
1380 let (publisher, app) = if let Some(slash) = name.find('/') {
1381 let (p, rest) = name.split_at(slash);
1382 (Some(p), &rest[1..])
1383 } else {
1384 (None, name)
1385 };
1386
1387 let valid_segment = |s: &str| -> bool {
1388 if s.is_empty() {
1389 return false;
1390 }
1391 let mut chars = s.chars();
1392 let first = chars.next().unwrap();
1393 if !first.is_ascii_lowercase() {
1394 return false;
1395 }
1396 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1397 };
1398
1399 if let Some(pub_name) = publisher {
1400 if !valid_segment(pub_name) {
1401 return Err(format!(
1402 "publisher segment '{}' must match [a-z][a-z0-9-]*",
1403 pub_name
1404 ));
1405 }
1406 }
1407
1408 if !valid_segment(app) {
1409 return Err(format!(
1410 "app name segment '{}' must match [a-z][a-z0-9-]*",
1411 app
1412 ));
1413 }
1414
1415 Ok(())
1416}
1417
1418#[cfg(test)]
1421mod tests {
1422 use super::*;
1423
1424 fn parse_ok(json: &str) -> AppManifest {
1425 AppManifest::from_json(json).expect("should parse")
1426 }
1427
1428 fn parse_err(json: &str) -> String {
1429 AppManifest::from_json(json).expect_err("should fail")
1430 }
1431
1432 #[test]
1435 fn v1_minimal_native() {
1436 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1437 assert_eq!(m.manifest_version, 1);
1438 assert_eq!(m.app_type, AppType::Native);
1439 assert!(m.abi.is_none());
1440 }
1441
1442 #[test]
1443 fn v1_minimal_bun() {
1444 let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
1445 assert_eq!(m.app_type, AppType::Bun);
1446 assert_eq!(m.effective_entrypoint(), "dist/index.js");
1447 }
1448
1449 #[test]
1450 fn v1_no_manifest_version_field_defaults_to_1() {
1451 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1452 assert_eq!(m.manifest_version, 1);
1453 }
1454
1455 #[test]
1456 fn v1_all_optional_fields_missing() {
1457 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1458 assert!(!m.critical);
1459 assert!(m.auto_start);
1460 assert!(!m.has_ui);
1461 assert_eq!(m.ui_path, "dist");
1462 assert!(m.permissions.is_empty());
1463 assert!(m.optional_permissions.is_empty());
1464 assert!(m.governor.is_none());
1467 assert!(m.subscribes.is_empty());
1468 }
1469
1470 #[test]
1471 fn v1_with_permissions_and_provides() {
1472 let json = r#"{
1473 "name": "example",
1474 "version": "1.0.0",
1475 "app_type": "bun",
1476 "permissions": ["core.storage.kv"],
1477 "optional_permissions": ["core.notifications.create"],
1478 "provides": {
1479 "core.example.run": { "description": "Run example job" }
1480 }
1481 }"#;
1482 let m = parse_ok(json);
1483 assert_eq!(m.permissions, vec!["core.storage.kv"]);
1484 assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
1485 assert!(m.provides.contains_key("core.example.run"));
1486 }
1487
1488 #[test]
1491 fn v2_minimal_native() {
1492 let json = r#"{
1493 "manifest_version": 2,
1494 "name": "cron",
1495 "version": "1.0.0",
1496 "app_type": "native",
1497 "abi": "v1",
1498 "entrypoint": "app.so",
1499 "hot_reload": "experimental"
1500 }"#;
1501 let m = parse_ok(json);
1502 assert_eq!(m.manifest_version, 2);
1503 assert_eq!(m.abi, Some(AbiVersion::V1));
1504 assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
1505 assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
1506 }
1507
1508 #[test]
1509 fn v2_minimal_bun_with_capabilities() {
1510 let json = r#"{
1511 "manifest_version": 2,
1512 "name": "example-fullstack",
1513 "version": "1.0.0",
1514 "app_type": "bun",
1515 "abi": "v1",
1516 "entrypoint": "dist/index.js",
1517 "hot_reload": "supported",
1518 "has_ui": true,
1519 "ui_path": "ui/dist",
1520 "capabilities": {
1521 "requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
1522 "provides": []
1523 },
1524 "governor": { "terminable": false, "min_idle_secs": 300 },
1525 "subscribes": ["core.chat.message.received"]
1526 }"#;
1527 let m = parse_ok(json);
1528 assert_eq!(m.manifest_version, 2);
1529 assert_eq!(m.capabilities.requires.len(), 2);
1530 let governor = m.governor.expect("governor block should parse");
1532 assert_eq!(governor.terminable, Some(false));
1533 assert_eq!(governor.min_idle_secs, Some(300));
1534 assert_eq!(m.subscribes, vec!["core.chat.message.received"]);
1535 }
1536
1537 #[test]
1538 fn stage_contract_fixture_parses_with_normalized_requirements() {
1539 let json = include_str!(
1540 "../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
1541 );
1542 let m = parse_ok(json);
1543 assert!(m.has_ui);
1544 assert_eq!(m.resolved_requires().unwrap(), vec!["core.metrics.latest"]);
1545 let ui = m.ui.expect("fixture should declare ui");
1546 assert_eq!(ui.kind, AppUiKind::Stage);
1547 assert_eq!(ui.entry, "ui/main.js");
1548 assert_eq!(ui.nav.unwrap().order, 10);
1549 }
1550
1551 #[test]
1552 fn omitted_ui_keeps_legacy_flags_without_fabricating_a_stage() {
1553 let m = parse_ok(
1554 r#"{"name":"legacy","version":"1.0.0","app_type":"bun","has_ui":true,"ui_path":"ui/dist"}"#,
1555 );
1556 assert!(m.has_ui);
1557 assert_eq!(m.ui_path, "ui/dist");
1558 assert!(m.ui.is_none());
1559 }
1560
1561 #[test]
1562 fn ui_requirements_are_typed_serialized_and_separate_from_backend_requires() {
1563 let manifest = parse_ok(
1564 r#"{
1565 "name":"ui-contract","version":"1.0.0","app_type":"bun",
1566 "requires":["core.cron.register"],
1567 "ui":{
1568 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
1569 "requires":{
1570 "capabilities":["ui.snapshot.v1"],
1571 "queries":["ui.query.v1"],
1572 "streams":["ui.event.v1"]
1573 },
1574 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
1575 }
1576 }"#,
1577 );
1578
1579 assert_eq!(
1580 manifest.resolved_requires().unwrap(),
1581 vec!["core.cron.register"]
1582 );
1583 let ui = manifest.ui.as_ref().expect("ui requirements should parse");
1584 assert_eq!(ui.requires.capabilities, vec!["ui.snapshot.v1"]);
1585 assert_eq!(ui.requires.queries, vec!["ui.query.v1"]);
1586 assert_eq!(ui.requires.streams, vec!["ui.event.v1"]);
1587 assert_eq!(
1588 ui.requires.resolved().unwrap(),
1589 vec!["ui.snapshot.v1", "ui.query.v1", "ui.event.v1"]
1590 );
1591 let serialized = serde_json::to_value(ui).unwrap();
1592 assert_eq!(
1593 serialized["requires"]["queries"],
1594 serde_json::json!(["ui.query.v1"])
1595 );
1596 }
1597
1598 #[test]
1599 fn ui_requirements_reject_blank_entries() {
1600 let error = parse_err(
1601 r#"{
1602 "name":"ui-contract","version":"1.0.0","app_type":"bun",
1603 "ui":{
1604 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
1605 "requires":{"capabilities":[""],"queries":[],"streams":[]},
1606 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
1607 }
1608 }"#,
1609 );
1610 assert!(error.contains("ui.requires entries must not be blank"));
1611 }
1612
1613 #[test]
1614 fn ui_data_namespace_stays_hyphen_only_when_declarations_allow_underscore() {
1615 assert!(validate_app_data_namespace("obs-viewer").is_ok());
1622
1623 let error = validate_app_data_namespace("obs_viewer")
1624 .expect_err("underscore must not be admitted into a storage namespace");
1625 assert!(
1626 error.contains("must match [a-z][a-z0-9-]*"),
1627 "unexpected error: {error}"
1628 );
1629 }
1630
1631 #[test]
1632 fn ui_data_query_capability_does_not_fall_back_to_backend_requires() {
1633 let error = parse_err(
1634 r#"{
1635 "name":"ui-data","version":"1.0.0","app_type":"bun",
1636 "requires":["ui.snapshot.v1"],
1637 "ui":{
1638 "kind":"stage","entry":"ui/main.js","title":"UI data","ui_api":1,
1639 "requires":{"capabilities":[],"queries":[],"streams":[]},
1640 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1641 "data":{
1642 "namespace":"ui-data","offline":"last-known","sync":{"kind":"cursor"},
1643 "queries":[{"name":"ui-data.snapshot.v1","capability":"ui.snapshot.v1","kind":"snapshot"}],
1644 "streams":[]
1645 }
1646 }
1647 }"#,
1648 );
1649 assert!(error.contains("must be declared in requires"));
1650 }
1651
1652 #[test]
1653 fn top_level_and_nested_requires_must_resolve_to_the_same_set() {
1654 let accepted = parse_ok(
1655 r#"{
1656 "name":"aliases","version":"1.0.0","app_type":"bun",
1657 "requires":["core.chat.read","core.chat.read","core.chat.send"],
1658 "capabilities":{"requires":["core.chat.send","core.chat.read"]}
1659 }"#,
1660 );
1661 assert_eq!(
1662 accepted.resolved_requires().unwrap(),
1663 vec!["core.chat.read", "core.chat.send"]
1664 );
1665
1666 let err = parse_err(
1667 r#"{
1668 "name":"aliases","version":"1.0.0","app_type":"bun",
1669 "requires":["core.chat.read"],
1670 "capabilities":{"requires":["core.wallet.pay"]}
1671 }"#,
1672 );
1673 assert!(err.contains("conflicts"), "unexpected error: {err}");
1674 }
1675
1676 #[test]
1677 fn stage_and_widget_ui_kinds_have_distinct_navigation_rules() {
1678 let base = |ui: &str| {
1679 format!(r#"{{"name":"stage","version":"1.0.0","app_type":"bun","ui":{ui}}}"#)
1680 };
1681 let widget = base(
1682 r#"{"kind":"widget","entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1683 );
1684 assert_eq!(
1685 parse_ok(&widget).ui.expect("widget ui").kind,
1686 AppUiKind::Widget
1687 );
1688 let widget_nav = base(
1689 r#"{"kind":"widget","entry":"ui/main.js","title":"Widget","nav":{"section":"default","order":1},"ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1690 );
1691 assert!(parse_err(&widget_nav).contains("must omit nav"));
1692 let api = base(
1693 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":2,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1694 );
1695 assert_eq!(parse_ok(&api).ui.expect("v2 stage ui").ui_api, 2);
1696 let unsupported_api = base(
1697 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":3,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1698 );
1699 assert!(parse_err(&unsupported_api).contains("ui_api"));
1700 let path = base(
1701 r#"{"kind":"stage","entry":"../main.js","title":"Stage","ui_api":1,"integrity":{"../main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1702 );
1703 assert!(parse_err(&path).contains("entry"));
1704 }
1705
1706 #[test]
1707 fn stage_requires_integrity_for_entry_and_icon() {
1708 let missing_entry = r#"{
1709 "name":"stage","version":"1.0.0","app_type":"bun",
1710 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{}}
1711 }"#;
1712 assert!(parse_err(missing_entry).contains("entry"));
1713 let missing_icon = r#"{
1714 "name":"stage","version":"1.0.0","app_type":"bun",
1715 "ui":{"entry":"ui/main.js","icon":"ui/icon.svg","title":"Stage","ui_api":1,
1716 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
1717 }"#;
1718 assert!(parse_err(missing_icon).contains("icon"));
1719 let uppercase = r#"{
1720 "name":"stage","version":"1.0.0","app_type":"bun",
1721 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
1722 "integrity":{"ui/main.js":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}
1723 }"#;
1724 assert!(parse_err(uppercase).contains("lowercase"));
1725 }
1726
1727 #[test]
1728 fn stage_composes_rejects_self_duplicate_and_unsafe_names() {
1729 let manifest = |composes: &str| {
1730 format!(
1731 r#"{{
1732 "name":"stage","version":"1.0.0","app_type":"bun",
1733 "ui":{{"entry":"ui/main.js","title":"Stage","ui_api":1,
1734 "composes":{composes},
1735 "integrity":{{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}}
1736 }}"#
1737 )
1738 };
1739 assert!(parse_err(&manifest(r#"["stage"]"#)).contains("itself"));
1740 assert!(parse_err(&manifest(r#"["chat","chat"]"#)).contains("duplicate"));
1741 assert!(parse_err(&manifest(r#"["../chat"]"#)).contains("invalid"));
1742 }
1743
1744 fn manifest_with_ui() -> AppManifest {
1756 parse_ok(
1757 r#"{
1758 "name":"stage","version":"1.0.0","app_type":"bun",
1759 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
1760 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1761 "ui/dist/surfaces/chip.js":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}
1762 }"#,
1763 )
1764 }
1765
1766 fn surface(id: &str, slot: &str) -> AppUiSurface {
1767 AppUiSurface {
1768 id: id.to_string(),
1769 slot: slot.to_string(),
1770 entry: "ui/dist/surfaces/chip.js".to_string(),
1771 title: "Chip".to_string(),
1772 order: 10,
1773 requires: AppUiRequirements {
1774 capabilities: vec!["wallet.balance.get".to_string()],
1775 ..Default::default()
1776 },
1777 }
1778 }
1779
1780 #[test]
1781 fn manifest_without_surfaces_still_parses() {
1782 let manifest = manifest_with_ui();
1783 assert!(manifest.ui.as_ref().unwrap().surfaces.is_empty());
1784 assert!(manifest.validate().is_ok());
1785 }
1786
1787 #[test]
1788 fn surface_in_a_known_slot_is_accepted() {
1789 let mut manifest = manifest_with_ui();
1790 manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "status-rail")];
1791 assert!(manifest.validate().is_ok());
1792 }
1793
1794 #[test]
1795 fn surface_in_an_unknown_slot_is_rejected() {
1796 let mut manifest = manifest_with_ui();
1797 manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "menu-bar")];
1798 let error = manifest.validate().unwrap_err();
1799 assert!(error.contains("menu-bar"), "unexpected error: {error}");
1800 }
1801
1802 #[test]
1803 fn duplicate_surface_ids_are_rejected() {
1804 let mut manifest = manifest_with_ui();
1805 manifest.ui.as_mut().unwrap().surfaces = vec![
1806 surface("chip", "status-rail"),
1807 surface("chip", "status-rail"),
1808 ];
1809 let error = manifest.validate().unwrap_err();
1810 assert!(error.contains("duplicate"), "unexpected error: {error}");
1811 }
1812
1813 #[test]
1814 fn surface_with_a_blank_id_is_rejected() {
1815 let mut manifest = manifest_with_ui();
1816 manifest.ui.as_mut().unwrap().surfaces = vec![surface(" ", "status-rail")];
1817 assert!(manifest.validate().is_err());
1818 }
1819
1820 #[test]
1821 fn surface_with_an_unsafe_entry_path_is_rejected() {
1822 let mut manifest = manifest_with_ui();
1823 let mut bad = surface("chip", "status-rail");
1824 bad.entry = "../../etc/passwd".to_string();
1825 manifest.ui.as_mut().unwrap().surfaces = vec![bad];
1826 assert!(manifest.validate().is_err());
1827 }
1828
1829 #[test]
1830 fn surface_entry_missing_from_integrity_is_rejected() {
1831 let mut manifest = manifest_with_ui();
1835 let mut unpinned = surface("chip", "status-rail");
1836 unpinned.entry = "ui/dist/surfaces/typo.js".to_string();
1837 manifest.ui.as_mut().unwrap().surfaces = vec![unpinned];
1838 let error = manifest.validate().unwrap_err();
1839 assert!(
1840 error.contains("ui.integrity must include surface 'chip' entry"),
1841 "unexpected error: {error}"
1842 );
1843 }
1844
1845 #[test]
1846 fn surface_with_a_blank_required_capability_is_rejected() {
1847 let mut manifest = manifest_with_ui();
1848 let mut bad = surface("chip", "status-rail");
1849 bad.requires.capabilities = vec![" ".to_string()];
1850 manifest.ui.as_mut().unwrap().surfaces = vec![bad];
1851 assert!(manifest.validate().is_err());
1852 }
1853
1854 #[test]
1855 fn v2_missing_abi_is_error() {
1856 let json = r#"{
1857 "manifest_version": 2,
1858 "name": "example",
1859 "version": "1.0.0",
1860 "app_type": "bun"
1861 }"#;
1862 let err = parse_err(json);
1863 assert!(err.contains("abi"), "expected abi error, got: {}", err);
1864 }
1865
1866 #[test]
1869 fn publisher_prefixed_name_accepted() {
1870 let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
1871 assert_eq!(m.name, "alice/weather");
1872 }
1873
1874 #[test]
1875 fn double_slash_name_rejected() {
1876 let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
1877 assert!(!err.is_empty());
1878 }
1879
1880 #[test]
1883 fn name_starting_with_digit_rejected() {
1884 let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
1885 assert!(!err.is_empty());
1886 }
1887
1888 #[test]
1889 fn name_with_uppercase_rejected() {
1890 let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
1891 assert!(!err.is_empty());
1892 }
1893
1894 #[test]
1895 fn empty_name_rejected() {
1896 let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
1897 assert!(!err.is_empty());
1898 }
1899
1900 #[test]
1903 fn path_traversal_double_dot_rejected() {
1904 let json = r#"{
1905 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1906 "app_type": "bun", "abi": "v1",
1907 "entrypoint": "../etc/passwd"
1908 }"#;
1909 let err = parse_err(json);
1910 assert!(err.contains(".."), "expected traversal error, got: {}", err);
1911 }
1912
1913 #[test]
1914 fn path_traversal_encoded_dot_not_decoded() {
1915 let json = r#"{
1917 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1918 "app_type": "bun", "abi": "v1",
1919 "entrypoint": "foo/../bar"
1920 }"#;
1921 let err = parse_err(json);
1922 assert!(!err.is_empty(), "should have failed: {}", err);
1923 }
1924
1925 #[test]
1926 fn absolute_path_rejected() {
1927 let json = r#"{
1928 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1929 "app_type": "bun", "abi": "v1",
1930 "entrypoint": "/usr/bin/sh"
1931 }"#;
1932 let err = parse_err(json);
1933 assert!(
1934 err.contains("absolute"),
1935 "expected absolute error, got: {}",
1936 err
1937 );
1938 }
1939
1940 #[test]
1941 fn shell_metachar_in_path_rejected() {
1942 let json = r#"{
1943 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1944 "app_type": "bun", "abi": "v1",
1945 "entrypoint": "dist/index.js;rm -rf /"
1946 }"#;
1947 let err = parse_err(json);
1948 assert!(!err.is_empty());
1949 }
1950
1951 #[test]
1952 fn valid_nested_path_accepted() {
1953 let json = r#"{
1954 "manifest_version": 2, "name": "my-app", "version": "1.0.0",
1955 "app_type": "bun", "abi": "v1",
1956 "entrypoint": "dist/index.js",
1957 "ui_path": "ui/dist"
1958 }"#;
1959 parse_ok(json);
1960 }
1961
1962 #[test]
1965 fn homepage_https_accepted() {
1966 let json = r#"{
1967 "name": "my-app", "version": "1.0.0", "app_type": "bun",
1968 "homepage": "https://example.com"
1969 }"#;
1970 parse_ok(json);
1971 }
1972
1973 #[test]
1974 fn homepage_javascript_scheme_rejected() {
1975 let json = r#"{
1976 "name": "my-app", "version": "1.0.0", "app_type": "bun",
1977 "homepage": "javascript:alert(1)"
1978 }"#;
1979 let err = parse_err(json);
1980 assert!(
1981 err.contains("scheme"),
1982 "expected scheme error, got: {}",
1983 err
1984 );
1985 }
1986
1987 #[test]
1988 fn homepage_file_scheme_rejected() {
1989 let json = r#"{
1990 "name": "my-app", "version": "1.0.0", "app_type": "bun",
1991 "homepage": "file:///etc/passwd"
1992 }"#;
1993 let err = parse_err(json);
1994 assert!(!err.is_empty());
1995 }
1996
1997 #[test]
2000 fn effective_entrypoint_native_default() {
2001 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2002 assert_eq!(m.effective_entrypoint(), "app.so");
2003 }
2004
2005 #[test]
2006 fn effective_entrypoint_bun_default() {
2007 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2008 assert_eq!(m.effective_entrypoint(), "dist/index.js");
2009 }
2010
2011 #[test]
2012 fn effective_hot_reload_native_default_is_experimental() {
2013 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2014 assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
2015 }
2016
2017 #[test]
2018 fn effective_hot_reload_bun_default_is_supported() {
2019 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2020 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
2021 }
2022
2023 #[test]
2024 fn hot_reload_unsupported_explicit() {
2025 let json = r#"{
2026 "manifest_version": 2, "name": "myapp", "version": "1.0.0",
2027 "app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
2028 }"#;
2029 let m = parse_ok(json);
2030 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
2031 }
2032
2033 #[test]
2036 fn validate_path_simple_valid() {
2037 assert!(validate_manifest_path("dist/index.js").is_ok());
2038 assert!(validate_manifest_path("app.so").is_ok());
2039 assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
2040 assert!(validate_manifest_path("build_output/main").is_ok());
2041 }
2042
2043 #[test]
2044 fn validate_path_empty_rejected() {
2045 assert!(validate_manifest_path("").is_err());
2046 }
2047
2048 #[test]
2049 fn validate_path_absolute_rejected() {
2050 assert!(validate_manifest_path("/usr/bin/sh").is_err());
2051 }
2052
2053 #[test]
2054 fn validate_path_double_dot_segment_rejected() {
2055 assert!(validate_manifest_path("foo/../bar").is_err());
2056 assert!(validate_manifest_path("../etc/passwd").is_err());
2057 }
2058
2059 #[test]
2060 fn validate_path_leading_dot_rejected() {
2061 assert!(validate_manifest_path(".hidden").is_err());
2062 }
2063
2064 #[test]
2065 fn validate_path_null_byte_rejected() {
2066 let path = "foo\0bar";
2068 assert!(validate_manifest_path(path).is_err());
2069 }
2070
2071 #[test]
2072 fn standalone_socket_path_development_override_is_explicit_and_pure() {
2073 let path = std::path::Path::new("/tmp/node-app/example.sock");
2074 assert!(validate_standalone_socket_path(path).is_err());
2075 assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
2076 assert!(validate_standalone_socket_path_with_policy(
2077 std::path::Path::new("/tmp/node-app/../escape.sock"),
2078 true,
2079 )
2080 .is_err());
2081 }
2082
2083 #[test]
2086 fn manifest_capabilities_defaults_to_empty() {
2087 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2088 assert!(m.capabilities.requires.is_empty());
2089 assert!(m.capabilities.provides.is_empty());
2090 }
2091
2092 #[test]
2095 fn resolved_capability_provides_v1_only() {
2096 let json = r#"{
2097 "name": "example", "version": "1.0.0", "app_type": "bun",
2098 "provides": { "core.example.run": { "description": "Run example job" } }
2099 }"#;
2100 let m = parse_ok(json);
2101 let out = m.resolved_capability_provides();
2102 assert_eq!(out.len(), 1);
2103 assert_eq!(
2104 out.get("core.example.run").unwrap().description,
2105 "Run example job"
2106 );
2107 }
2108
2109 #[test]
2110 fn resolved_capability_provides_v2_names_get_blank_declaration() {
2111 let json = r#"{
2112 "manifest_version": 2, "name": "example", "version": "1.0.0",
2113 "app_type": "bun", "abi": "v1",
2114 "capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
2115 }"#;
2116 let m = parse_ok(json);
2117 let out = m.resolved_capability_provides();
2118 assert_eq!(out.len(), 2);
2119 assert_eq!(out.get("core.example.run").unwrap().description, "");
2120 assert!(out.get("core.example.run").unwrap().schema.is_none());
2121 assert!(out.contains_key("core.example.other"));
2123 assert!(!out.contains_key("core.example.other:extra"));
2124 }
2125
2126 #[test]
2127 fn resolved_capability_provides_v1_wins_on_conflict() {
2128 let json = r#"{
2129 "manifest_version": 2, "name": "example", "version": "1.0.0",
2130 "app_type": "bun", "abi": "v1",
2131 "provides": { "core.example.run": { "description": "v1 wins" } },
2132 "capabilities": { "requires": [], "provides": ["core.example.run"] }
2133 }"#;
2134 let m = parse_ok(json);
2135 let out = m.resolved_capability_provides();
2136 assert_eq!(out.len(), 1);
2137 assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
2138 }
2139
2140 #[test]
2141 fn resolved_capability_provides_blank_v2_name_skipped() {
2142 let json = r#"{
2143 "manifest_version": 2, "name": "example", "version": "1.0.0",
2144 "app_type": "bun", "abi": "v1",
2145 "capabilities": { "requires": [], "provides": [" ", "core.example.run"] }
2146 }"#;
2147 let m = parse_ok(json);
2148 let out = m.resolved_capability_provides();
2149 assert_eq!(out.len(), 1);
2150 assert!(out.contains_key("core.example.run"));
2151 }
2152
2153 #[test]
2154 fn resolved_capability_provides_empty_manifest_yields_empty_map() {
2155 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2156 assert!(m.resolved_capability_provides().is_empty());
2157 }
2158
2159 #[test]
2162 fn abi_v1_is_supported() {
2163 assert!(AbiVersion::V1.is_supported());
2164 }
2165
2166 #[test]
2169 fn app_tier_display() {
2170 assert_eq!(AppTier::FirstParty.to_string(), "first_party");
2171 assert_eq!(AppTier::Optional.to_string(), "optional");
2172 assert_eq!(AppTier::Development.to_string(), "development");
2173 }
2174
2175 #[test]
2176 fn app_tier_serde_roundtrip() {
2177 for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
2179 let json = serde_json::to_string(&tier).unwrap();
2180 let back: AppTier = serde_json::from_str(&json).unwrap();
2181 assert_eq!(
2182 tier, back,
2183 "roundtrip failed for {:?}: serialized as {}",
2184 tier, json
2185 );
2186 }
2187 assert_eq!(
2188 serde_json::to_string(&AppTier::Development).unwrap(),
2189 "\"development\""
2190 );
2191 }
2192
2193 #[test]
2196 fn app_type_display() {
2197 assert_eq!(AppType::Native.to_string(), "native");
2198 assert_eq!(AppType::Bun.to_string(), "bun");
2199 assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
2200 }
2201
2202 #[test]
2203 fn platform_runtime_is_a_supported_packaging_type() {
2204 let manifest: AppManifest = serde_json::from_value(serde_json::json!({
2205 "manifest_version": 2,
2206 "abi": "v1",
2207 "name": "bun-runtime",
2208 "version": "1.0.0",
2209 "app_type": "platform-runtime",
2210 "entrypoint": "bun"
2211 }))
2212 .expect("platform runtime manifest should parse");
2213
2214 assert_eq!(manifest.app_type, AppType::PlatformRuntime);
2215 assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
2216 }
2217
2218 #[test]
2221 fn governor_manifest_memory_budget_defaults_to_none() {
2222 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2223 assert_eq!(parsed.memory_budget_kb, None);
2224 }
2225
2226 #[test]
2227 fn governor_manifest_parses_declared_memory_budget() {
2228 let parsed: GovernorManifest =
2229 serde_json::from_str(r#"{"memory_budget_kb": 40960}"#).unwrap();
2230 assert_eq!(parsed.memory_budget_kb, Some(40_960));
2231 }
2232
2233 #[test]
2236 fn latency_class_parses_and_defaults_none() {
2237 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2238 assert_eq!(parsed.latency_class, None);
2239 }
2240
2241 #[test]
2242 fn latency_class_parses_declared_interactive() {
2243 let json = r#"{
2244 "name": "example",
2245 "version": "1.0.0",
2246 "app_type": "bun",
2247 "governor": {"latency_class": "interactive"}
2248 }"#;
2249 let m = parse_ok(json);
2250 let governor = m.governor.expect("governor block should parse");
2251 assert_eq!(governor.latency_class, Some(LatencyClass::Interactive));
2252 }
2253
2254 #[test]
2255 fn latency_class_parses_declared_background() {
2256 let parsed: GovernorManifest =
2257 serde_json::from_str(r#"{"latency_class": "background"}"#).unwrap();
2258 assert_eq!(parsed.latency_class, Some(LatencyClass::Background));
2259 }
2260
2261 #[test]
2262 fn latency_class_invalid_value_is_parse_error() {
2263 let result: Result<GovernorManifest, _> =
2264 serde_json::from_str(r#"{"latency_class": "urgent"}"#);
2265 assert!(result.is_err(), "unknown latency_class value must fail to parse");
2266 }
2267
2268 #[test]
2269 fn latency_class_as_str_and_from_str_roundtrip() {
2270 for class in [LatencyClass::Interactive, LatencyClass::Background] {
2271 let s = class.as_str();
2272 assert_eq!(LatencyClass::from_str(s), Ok(class));
2273 }
2274 assert!(LatencyClass::from_str("urgent").is_err());
2275 }
2276}
2277
2278#[cfg(test)]
2279mod shared_app_data_corpus_tests {
2280 use super::*;
2281
2282 #[test]
2287 fn shared_app_data_corpus_matches_the_host_validator() {
2288 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/app-data");
2289 let mut checked = 0;
2290 for entry in std::fs::read_dir(&dir).expect("fixture directory must exist") {
2291 let path = entry.expect("readable entry").path();
2292 if path.extension().and_then(|e| e.to_str()) != Some("json") {
2293 continue;
2294 }
2295 let fixture: serde_json::Value =
2296 serde_json::from_str(&std::fs::read_to_string(&path).expect("readable fixture"))
2297 .expect("valid fixture json");
2298 let name = path
2299 .file_name()
2300 .and_then(|n| n.to_str())
2301 .unwrap_or("?")
2302 .to_string();
2303 let manifest = serde_json::json!({
2304 "manifest_version": 2, "abi": "v1", "name": "fixture", "version": "0.1.0",
2305 "app_type": "bun", "entrypoint": "dist/index.js",
2306 "ui": fixture["ui"],
2307 });
2308 let result = AppManifest::from_json(&manifest.to_string()).and_then(|m| m.validate());
2309 match fixture["expect"].as_str().expect("expect field") {
2310 "accept" => assert!(result.is_ok(), "{name} should be accepted: {result:?}"),
2311 "reject" => {
2312 let error = result.expect_err(&format!("{name} should be rejected"));
2313 let reason = fixture["reason"]
2314 .as_str()
2315 .expect("reject fixtures need a reason");
2316 assert!(
2317 error.contains(reason),
2318 "{name}: {error:?} should mention {reason:?}"
2319 );
2320 }
2321 other => panic!("{name}: unknown expect {other:?}"),
2322 }
2323 checked += 1;
2324 }
2325 assert!(checked >= 10, "expected the full corpus, walked {checked}");
2327 }
2328}