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}
52
53impl AppType {
54 pub fn as_str(self) -> &'static str {
55 match self {
56 AppType::Native => "native",
57 AppType::Bun => "bun",
58 AppType::Standalone => "standalone",
59 AppType::PlatformRuntime => "platform-runtime",
60 }
61 }
62}
63
64impl std::fmt::Display for AppType {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 write!(f, "{}", self.as_str())
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum AppTier {
77 FirstParty,
81 Optional,
85 Development,
96}
97
98impl AppTier {
99 pub fn as_str(self) -> &'static str {
100 match self {
101 AppTier::FirstParty => "first_party",
102 AppTier::Optional => "optional",
103 AppTier::Development => "development",
104 }
105 }
106}
107
108impl std::fmt::Display for AppTier {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 write!(f, "{}", self.as_str())
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "lowercase")]
120pub enum AbiVersion {
121 V1,
122}
123
124impl AbiVersion {
125 pub fn as_str(&self) -> &'static str {
126 match self {
127 AbiVersion::V1 => "v1",
128 }
129 }
130
131 pub fn is_supported(&self) -> bool {
133 matches!(self, AbiVersion::V1)
134 }
135}
136
137impl std::fmt::Display for AbiVersion {
138 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139 write!(f, "{}", self.as_str())
140 }
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub enum HotReloadKind {
150 Supported,
152 Experimental,
155 Unsupported,
157}
158
159impl HotReloadKind {
160 pub fn default_for(app_type: AppType) -> Self {
161 match app_type {
162 AppType::Native => HotReloadKind::Experimental,
163 AppType::Bun => HotReloadKind::Supported,
164 AppType::Standalone => HotReloadKind::Unsupported,
167 AppType::PlatformRuntime => HotReloadKind::Unsupported,
168 }
169 }
170}
171
172#[derive(Debug, Clone, Default, Serialize, Deserialize)]
179pub struct ManifestCapabilities {
180 #[serde(default)]
183 pub requires: Vec<String>,
184
185 #[serde(default)]
188 pub provides: Vec<String>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, Default)]
193pub struct ProvidedScope {
194 pub scope: String,
195 pub description: String,
196 pub resource_pattern: String,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct EndpointPolicy {
202 pub method: String,
203 pub path: String,
204 pub required_permissions: Vec<String>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct ProvidedCapability {
210 #[serde(default)]
211 pub description: String,
212 #[serde(default)]
213 pub schema: Option<serde_json::Value>,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct StandaloneConfig {
228 pub socket_path: std::path::PathBuf,
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "snake_case")]
234pub enum AppUiKind {
235 Stage,
236 Widget,
237}
238
239fn default_app_ui_kind() -> AppUiKind {
240 AppUiKind::Stage
241}
242
243fn default_nav_section() -> String {
244 "default".to_string()
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct AppUiNav {
250 #[serde(default = "default_nav_section")]
251 pub section: String,
252 #[serde(default)]
253 pub order: i32,
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(rename_all = "kebab-case")]
259pub enum AppDataOfflinePolicy {
260 LastKnown,
262 OnlineOnly,
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(rename_all = "kebab-case")]
269pub enum AppDataQueryKind {
270 Collection,
271 Detail,
272 Snapshot,
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(rename_all = "kebab-case")]
278pub enum AppDataStreamKind {
279 Changes,
280 Events,
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(rename_all = "kebab-case")]
286pub enum AppDataSyncKind {
287 Cursor,
288 Snapshot,
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct AppDataSyncPolicy {
294 pub kind: AppDataSyncKind,
295 pub cursor_ttl_secs: Option<u32>,
296 pub full_refresh_interval_secs: Option<u32>,
297 pub retention_secs: Option<u32>,
298}
299
300impl Serialize for AppDataSyncPolicy {
301 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
302 where
303 S: serde::Serializer,
304 {
305 use serde::ser::SerializeStruct;
306
307 if self.cursor_ttl_secs.is_none()
308 && self.full_refresh_interval_secs.is_none()
309 && self.retention_secs.is_none()
310 {
311 return self.kind.serialize(serializer);
312 }
313
314 let mut state = serializer.serialize_struct("AppDataSyncPolicy", 4)?;
315 state.serialize_field("kind", &self.kind)?;
316 if let Some(cursor_ttl_secs) = self.cursor_ttl_secs {
317 state.serialize_field("cursor_ttl_secs", &cursor_ttl_secs)?;
318 }
319 if let Some(full_refresh_interval_secs) = self.full_refresh_interval_secs {
320 state.serialize_field("full_refresh_interval_secs", &full_refresh_interval_secs)?;
321 }
322 if let Some(retention_secs) = self.retention_secs {
323 state.serialize_field("retention_secs", &retention_secs)?;
324 }
325 state.end()
326 }
327}
328
329impl<'de> Deserialize<'de> for AppDataSyncPolicy {
330 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
331 where
332 D: serde::Deserializer<'de>,
333 {
334 #[derive(Deserialize)]
335 #[serde(deny_unknown_fields)]
336 struct ObjectPolicy {
337 kind: AppDataSyncKind,
338 #[serde(default)]
339 cursor_ttl_secs: Option<u32>,
340 #[serde(default)]
341 full_refresh_interval_secs: Option<u32>,
342 #[serde(default)]
343 retention_secs: Option<u32>,
344 }
345
346 #[derive(Deserialize)]
347 #[serde(untagged)]
348 enum WirePolicy {
349 Kind(AppDataSyncKind),
350 Object(ObjectPolicy),
351 }
352
353 match WirePolicy::deserialize(deserializer)? {
354 WirePolicy::Kind(kind) => Ok(Self {
355 kind,
356 cursor_ttl_secs: None,
357 full_refresh_interval_secs: None,
358 retention_secs: None,
359 }),
360 WirePolicy::Object(policy) => Ok(Self {
361 kind: policy.kind,
362 cursor_ttl_secs: policy.cursor_ttl_secs,
363 full_refresh_interval_secs: policy.full_refresh_interval_secs,
364 retention_secs: policy.retention_secs,
365 }),
366 }
367 }
368}
369
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
372#[serde(deny_unknown_fields)]
373pub struct AppDataQueryDeclaration {
374 pub name: String,
375 pub capability: String,
376 pub kind: AppDataQueryKind,
377}
378
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
381#[serde(deny_unknown_fields)]
382pub struct AppDataStreamDeclaration {
383 pub name: String,
384 pub kind: AppDataStreamKind,
385}
386
387#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
389#[serde(deny_unknown_fields)]
390pub struct AppDataManifest {
391 pub namespace: String,
392 pub offline: AppDataOfflinePolicy,
393 pub sync: AppDataSyncPolicy,
394 #[serde(default)]
395 pub queries: Vec<AppDataQueryDeclaration>,
396 #[serde(default)]
397 pub streams: Vec<AppDataStreamDeclaration>,
398}
399
400#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
405#[serde(deny_unknown_fields)]
406pub struct AppUiRequirements {
407 #[serde(default)]
408 pub capabilities: Vec<String>,
409 #[serde(default)]
410 pub queries: Vec<String>,
411 #[serde(default)]
412 pub streams: Vec<String>,
413}
414
415impl AppUiRequirements {
416 pub fn resolved(&self) -> Result<Vec<String>, String> {
420 let mut resolved = Vec::new();
421 let mut seen = HashSet::new();
422 for (values, allow_wildcard) in [
423 (&self.capabilities, true),
424 (&self.queries, false),
425 (&self.streams, false),
426 ] {
427 for value in values {
428 let requirement = value.trim();
429 if requirement.is_empty() {
430 return Err("ui.requires entries must not be blank".to_string());
431 }
432 validate_ui_requirement_name(requirement, allow_wildcard)
433 .map_err(|error| format!("ui.requires entry '{requirement}' invalid: {error}"))?;
434 if seen.insert(requirement.to_string()) {
435 resolved.push(requirement.to_string());
436 }
437 }
438 }
439 Ok(resolved)
440 }
441}
442
443#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
445pub struct AppUiManifest {
446 #[serde(default = "default_app_ui_kind")]
447 pub kind: AppUiKind,
448 pub entry: String,
449 pub title: String,
450 #[serde(default)]
451 pub icon: Option<String>,
452 #[serde(default)]
453 pub nav: Option<AppUiNav>,
454 #[serde(default)]
455 pub composes: Vec<String>,
456 pub ui_api: u8,
457 #[serde(default)]
458 pub integrity: BTreeMap<String, String>,
459 #[serde(default)]
463 pub description: Option<String>,
464 #[serde(default)]
467 pub keywords: Vec<String>,
468 #[serde(default)]
471 pub requires: AppUiRequirements,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
473 pub data: Option<AppDataManifest>,
474}
475
476pub fn validate_manifest_path(path: &str) -> Result<(), String> {
488 if path.is_empty() {
489 return Err("path must not be empty".to_string());
490 }
491
492 if path.starts_with('/') {
494 return Err(format!(
495 "path '{}' must not be absolute (starts with /)",
496 path
497 ));
498 }
499
500 let first = path.chars().next().unwrap();
503 if !first.is_ascii_alphanumeric() && first != '_' {
504 return Err(format!(
505 "path '{}' must begin with an alphanumeric character or underscore",
506 path
507 ));
508 }
509 for ch in path.chars().skip(1) {
510 if !ch.is_ascii_alphanumeric() && !matches!(ch, '_' | '.' | '/' | '-') {
511 return Err(format!(
512 "path '{}' contains disallowed character '{}'",
513 path, ch
514 ));
515 }
516 }
517
518 for segment in path.split('/') {
520 if segment == ".." {
521 return Err(format!(
522 "path '{}' contains a '..' segment (path traversal rejected)",
523 path
524 ));
525 }
526 }
527
528 Ok(())
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize)]
539pub struct AppManifest {
540 #[serde(default = "default_manifest_version", rename = "manifest_version")]
542 pub manifest_version: u8,
543
544 pub name: String,
545 pub version: String,
546
547 #[serde(default = "default_app_type_native")]
548 pub app_type: AppType,
549
550 #[serde(default)]
551 pub description: String,
552
553 pub abi: Option<AbiVersion>,
556
557 pub entrypoint: Option<String>,
560
561 pub hot_reload: Option<HotReloadKind>,
564
565 #[serde(default)]
567 pub critical: bool,
568
569 #[serde(
570 default = "default_auto_start",
571 deserialize_with = "deserialize_auto_start"
572 )]
573 pub auto_start: bool,
574
575 #[serde(default)]
576 pub has_ui: bool,
577
578 #[serde(default = "default_ui_path")]
579 pub ui_path: String,
580
581 #[serde(default)]
582 pub permissions: Vec<String>,
583
584 #[serde(default)]
587 pub requires: Vec<String>,
588
589 #[serde(default)]
590 pub optional_permissions: Vec<String>,
591
592 #[serde(default)]
593 pub provides_scopes: Vec<ProvidedScope>,
594
595 #[serde(default)]
596 pub endpoint_policies: Vec<EndpointPolicy>,
597
598 #[serde(default)]
599 pub capability_scopes: HashMap<String, String>,
600
601 #[serde(default)]
602 pub provides: HashMap<String, ProvidedCapability>,
603
604 #[serde(default)]
606 pub capabilities: ManifestCapabilities,
607
608 #[serde(default, skip_serializing_if = "Option::is_none")]
611 pub ui: Option<AppUiManifest>,
612
613 #[serde(default)]
615 pub author: Option<String>,
616
617 #[serde(default)]
618 pub homepage: Option<String>,
619
620 #[serde(default)]
621 pub depends_on: Option<Vec<String>>,
622
623 #[serde(default)]
624 pub boot_priority: Option<u32>,
625
626 #[serde(default)]
630 pub governor: Option<GovernorManifest>,
631
632 #[serde(default)]
638 pub subscribes: Vec<String>,
639
640 #[serde(default)]
644 pub standalone: Option<StandaloneConfig>,
645
646 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub tcp: Option<TcpManifest>,
650}
651
652#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
655pub struct GovernorManifest {
656 #[serde(default)]
660 pub terminable: Option<bool>,
661
662 #[serde(default)]
666 pub min_idle_secs: Option<u64>,
667
668 #[serde(default)]
701 pub memory_budget_kb: Option<u64>,
702}
703
704#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
708pub struct TcpManifest {
709 #[serde(default, skip_serializing_if = "Option::is_none")]
713 pub preferred_port: Option<u16>,
714
715 #[serde(default, skip_serializing_if = "Option::is_none")]
722 pub direct_bind: Option<bool>,
723}
724
725fn default_manifest_version() -> u8 {
726 1
727}
728
729fn default_auto_start() -> bool {
730 true
731}
732
733fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
739where
740 D: serde::Deserializer<'de>,
741{
742 #[derive(Deserialize)]
743 #[serde(untagged)]
744 enum BoolOrStr {
745 Bool(bool),
746 Str(String),
747 }
748 Ok(match BoolOrStr::deserialize(deserializer)? {
749 BoolOrStr::Bool(b) => b,
750 BoolOrStr::Str(s) => matches!(
751 s.trim().to_ascii_lowercase().as_str(),
752 "true" | "eager" | "active" | "auto" | "on" | "1"
753 ),
754 })
755}
756
757fn default_ui_path() -> String {
758 "dist".to_string()
759}
760
761fn default_app_type_native() -> AppType {
762 AppType::Native
763}
764
765impl AppManifest {
766 pub fn resolved_requires(&self) -> Result<Vec<String>, String> {
770 let top = normalized_requirements(&self.requires)?;
771 let nested = normalized_requirements(&self.capabilities.requires)?;
772 if !top.is_empty()
773 && !nested.is_empty()
774 && top.iter().cloned().collect::<BTreeSet<_>>()
775 != nested.iter().cloned().collect::<BTreeSet<_>>()
776 {
777 return Err("top-level 'requires' conflicts with 'capabilities.requires'".to_string());
778 }
779 Ok(if !top.is_empty() { top } else { nested })
780 }
781
782 pub fn effective_hot_reload(&self) -> HotReloadKind {
785 self.hot_reload
786 .unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
787 }
788
789 pub fn effective_entrypoint(&self) -> &str {
794 if let Some(ref ep) = self.entrypoint {
795 ep.as_str()
796 } else {
797 match self.app_type {
798 AppType::Native => "app.so",
799 AppType::Bun => "dist/index.js",
800 AppType::Standalone => "",
801 AppType::PlatformRuntime => "bun",
802 }
803 }
804 }
805
806 pub fn has_capability_providers(&self) -> bool {
809 !self.provides.is_empty() || !self.capabilities.provides.is_empty()
810 }
811
812 pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
826 let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
827 for raw in &self.capabilities.provides {
828 let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
829 if name.is_empty() {
830 continue;
831 }
832 out.entry(name).or_insert(ProvidedCapability {
833 description: String::new(),
834 schema: None,
835 });
836 }
837 out
838 }
839
840 pub fn validate(&self) -> Result<(), String> {
845 self.validate_with_socket_path_policy(false)
846 }
847
848 pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
853 if self.manifest_version == 2 && self.abi.is_none() {
855 return Err("manifest_version 2 requires an 'abi' field".to_string());
856 }
857
858 validate_app_name(&self.name)?;
861 self.resolved_requires()?;
862
863 if let Some(ref ep) = self.entrypoint {
865 validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
866 }
867 if !self.ui_path.is_empty() && self.ui_path != "dist" {
869 validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
870 }
871
872 if let Some(ui) = &self.ui {
873 validate_app_ui(&self.name, ui)?;
874 }
875
876 if let Some(ref hp) = self.homepage {
878 if !hp.starts_with("https://") && !hp.starts_with("http://") {
879 return Err(format!(
880 "homepage '{}' must use https:// or http:// scheme",
881 hp
882 ));
883 }
884 }
885
886 match self.app_type {
893 AppType::Standalone => {
894 if self.has_capability_providers() {
895 let cfg = self.standalone.as_ref().ok_or_else(|| {
896 "standalone apps that declare 'provides' require a \
897 'standalone.socket_path' field"
898 .to_string()
899 })?;
900 validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
901 }
902 }
903 AppType::Native | AppType::Bun | AppType::PlatformRuntime => {
904 if self.standalone.is_some() {
905 return Err(format!(
906 "'standalone' block is only valid when app_type == 'standalone' \
907 (found app_type='{}')",
908 self.app_type
909 ));
910 }
911 }
912 }
913
914 if self.app_type == AppType::PlatformRuntime
915 && !self.resolved_capability_provides().is_empty()
916 {
917 return Err(
918 "platform-runtime packages cannot provide runtime capabilities".to_string(),
919 );
920 }
921
922 Ok(())
923 }
924
925 pub fn from_json(json: &str) -> Result<Self, String> {
927 Self::from_json_with_socket_path_policy(json, false)
928 }
929
930 pub fn from_json_with_socket_path_policy(
932 json: &str,
933 allow_non_run: bool,
934 ) -> Result<Self, String> {
935 let mut manifest: Self =
936 serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
937 if manifest.ui.is_some() {
938 manifest.has_ui = true;
939 }
940 manifest.validate_with_socket_path_policy(allow_non_run)?;
941 Ok(manifest)
942 }
943}
944
945fn normalized_requirements(values: &[String]) -> Result<Vec<String>, String> {
946 let mut seen = HashSet::new();
947 let mut resolved = Vec::new();
948 for value in values {
949 let requirement = value.trim();
950 if requirement.is_empty() {
951 return Err("capability requirements must not be blank".to_string());
952 }
953 if seen.insert(requirement.to_string()) {
954 resolved.push(requirement.to_string());
955 }
956 }
957 Ok(resolved)
958}
959
960fn validate_app_ui(
961 app_name: &str,
962 ui: &AppUiManifest,
963) -> Result<(), String> {
964 if ui.ui_api != 1 && ui.ui_api != 2 {
965 return Err(format!(
966 "ui.ui_api {} is unsupported; only versions 1 and 2 are supported",
967 ui.ui_api
968 ));
969 }
970 if ui.title.trim().is_empty() {
971 return Err("ui.title must not be blank".to_string());
972 }
973 ui.requires.resolved()?;
974 validate_manifest_path(&ui.entry).map_err(|error| format!("ui.entry invalid: {error}"))?;
975 if let Some(icon) = &ui.icon {
976 validate_manifest_path(icon).map_err(|error| format!("ui.icon invalid: {error}"))?;
977 }
978 if let Some(nav) = &ui.nav {
979 if ui.kind == AppUiKind::Widget {
980 return Err("widget ui must omit nav metadata".to_string());
981 }
982 if nav.section.trim().is_empty() {
983 return Err("ui.nav.section must not be blank".to_string());
984 }
985 }
986 if let Some(data) = &ui.data {
987 if ui.kind != AppUiKind::Stage {
988 return Err("widget ui must omit app data declarations".to_string());
989 }
990 validate_app_data(data, &ui.requires.resolved()?)?;
991 }
992
993 let mut composed = HashSet::new();
994 for name in &ui.composes {
995 validate_app_name(name).map_err(|error| format!("ui.composes entry invalid: {error}"))?;
996 if name == app_name {
997 return Err("ui.composes must not contain the app itself".to_string());
998 }
999 if !composed.insert(name) {
1000 return Err(format!("ui.composes contains duplicate app '{name}'"));
1001 }
1002 }
1003
1004 for (path, digest) in &ui.integrity {
1005 validate_manifest_path(path)
1006 .map_err(|error| format!("ui.integrity path invalid: {error}"))?;
1007 if !is_lowercase_sha256(digest) {
1008 return Err(format!(
1009 "ui.integrity digest for '{path}' must be a lowercase 64-character SHA-256"
1010 ));
1011 }
1012 }
1013 if !ui.integrity.contains_key(&ui.entry) {
1014 return Err("ui.integrity must include the declared entry".to_string());
1015 }
1016 if let Some(icon) = &ui.icon {
1017 if !ui.integrity.contains_key(icon) {
1018 return Err("ui.integrity must include the declared icon".to_string());
1019 }
1020 }
1021 Ok(())
1022}
1023
1024fn validate_app_data(data: &AppDataManifest, resolved_requires: &[String]) -> Result<(), String> {
1025 validate_app_data_namespace(&data.namespace)?;
1026 validate_app_data_sync_policy(&data.sync)?;
1027 if data.queries.is_empty() {
1028 return Err("ui.data.queries must declare at least one query".to_string());
1029 }
1030
1031 let requires: BTreeSet<&str> = resolved_requires.iter().map(String::as_str).collect();
1032 let mut names = BTreeSet::new();
1033 for query in &data.queries {
1034 validate_namespaced_data_name(&query.name, &data.namespace)
1035 .map_err(|error| format!("ui.data query '{}' invalid: {error}", query.name))?;
1036 validate_capability_name(&query.capability).map_err(|error| {
1037 format!(
1038 "ui.data query '{}' capability '{}' invalid: {error}",
1039 query.name, query.capability
1040 )
1041 })?;
1042 if !requires.contains(query.capability.as_str()) {
1043 return Err(format!(
1044 "ui.data query '{}' capability '{}' must be declared in requires",
1045 query.name, query.capability
1046 ));
1047 }
1048 if !names.insert(query.name.as_str()) {
1049 return Err(format!("ui.data contains duplicate query '{}'", query.name));
1050 }
1051 }
1052
1053 for stream in &data.streams {
1054 validate_namespaced_data_name(&stream.name, &data.namespace)
1055 .map_err(|error| format!("ui.data stream '{}' invalid: {error}", stream.name))?;
1056 if !names.insert(stream.name.as_str()) {
1057 return Err(format!(
1058 "ui.data contains duplicate declaration '{}'",
1059 stream.name
1060 ));
1061 }
1062 }
1063
1064 Ok(())
1065}
1066
1067fn validate_app_data_namespace(namespace: &str) -> Result<(), String> {
1068 if !is_safe_name_segment(namespace) {
1069 return Err(format!(
1070 "ui.data namespace '{}' must match [a-z][a-z0-9-]*",
1071 namespace
1072 ));
1073 }
1074 if matches!(
1075 namespace,
1076 "core" | "internal" | "node" | "platform" | "system"
1077 ) {
1078 return Err(format!("ui.data namespace '{namespace}' is reserved"));
1079 }
1080 Ok(())
1081}
1082
1083fn validate_app_data_sync_policy(sync: &AppDataSyncPolicy) -> Result<(), String> {
1084 validate_optional_range("cursor_ttl_secs", sync.cursor_ttl_secs, 60, 86_400)?;
1085 validate_optional_range(
1086 "full_refresh_interval_secs",
1087 sync.full_refresh_interval_secs,
1088 60,
1089 604_800,
1090 )?;
1091 validate_optional_range("retention_secs", sync.retention_secs, 300, 31_536_000)?;
1092 if sync.kind == AppDataSyncKind::Snapshot && sync.cursor_ttl_secs.is_some() {
1093 return Err("ui.data.sync cursor_ttl_secs is only valid for cursor sync".to_string());
1094 }
1095 Ok(())
1096}
1097
1098fn validate_optional_range(
1099 field: &str,
1100 value: Option<u32>,
1101 min: u32,
1102 max: u32,
1103) -> Result<(), String> {
1104 if let Some(value) = value {
1105 if value < min || value > max {
1106 return Err(format!(
1107 "ui.data.sync {field} must be between {min} and {max} seconds"
1108 ));
1109 }
1110 }
1111 Ok(())
1112}
1113
1114fn validate_namespaced_data_name(name: &str, namespace: &str) -> Result<(), String> {
1115 validate_capability_name(name)?;
1116 let Some(rest) = name
1117 .strip_prefix(namespace)
1118 .and_then(|suffix| suffix.strip_prefix('.'))
1119 else {
1120 return Err(format!("name must use namespace '{namespace}'"));
1121 };
1122 if rest.is_empty() {
1123 return Err("name must include a value after its namespace".to_string());
1124 }
1125 if !has_version_suffix(name) {
1126 return Err("name must end with a .vN version suffix".to_string());
1127 }
1128 Ok(())
1129}
1130
1131fn validate_capability_name(name: &str) -> Result<(), String> {
1132 if name.is_empty() {
1133 return Err("name must not be empty".to_string());
1134 }
1135 if name.contains('/') || name.contains("..") {
1136 return Err("name must not contain path separators or traversal".to_string());
1137 }
1138 if !name.split('.').all(is_safe_declaration_segment) {
1139 return Err("name must contain only lowercase dot-separated segments".to_string());
1140 }
1141 Ok(())
1142}
1143
1144fn validate_ui_requirement_name(name: &str, allow_wildcard: bool) -> Result<(), String> {
1145 if allow_wildcard && name.ends_with(".*") {
1146 return validate_capability_name(&name[..name.len() - 2]);
1147 }
1148 validate_capability_name(name)
1149}
1150
1151fn has_version_suffix(name: &str) -> bool {
1152 let Some(version) = name.rsplit('.').next() else {
1153 return false;
1154 };
1155 let Some(digits) = version.strip_prefix('v') else {
1156 return false;
1157 };
1158 !digits.is_empty()
1159 && !digits.starts_with('0')
1160 && digits.bytes().all(|byte| byte.is_ascii_digit())
1161}
1162
1163fn is_safe_name_segment(segment: &str) -> bool {
1164 if segment.is_empty() {
1165 return false;
1166 }
1167 let mut chars = segment.chars();
1168 let Some(first) = chars.next() else {
1169 return false;
1170 };
1171 first.is_ascii_lowercase()
1172 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
1173}
1174
1175fn is_safe_declaration_segment(segment: &str) -> bool {
1188 let mut chars = segment.chars();
1189 let Some(first) = chars.next() else {
1190 return false;
1191 };
1192 first.is_ascii_lowercase()
1193 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
1194}
1195
1196fn is_lowercase_sha256(value: &str) -> bool {
1197 value.len() == 64
1198 && value
1199 .bytes()
1200 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1201}
1202
1203pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
1212 validate_standalone_socket_path_with_policy(path, false)
1213}
1214
1215pub fn validate_standalone_socket_path_with_policy(
1217 path: &std::path::Path,
1218 allow_non_run: bool,
1219) -> Result<(), String> {
1220 if !path.is_absolute() {
1221 return Err(format!(
1222 "standalone.socket_path '{}' must be absolute",
1223 path.display()
1224 ));
1225 }
1226 if !allow_non_run && !path.starts_with("/run/") {
1227 return Err(format!(
1228 "standalone.socket_path '{}' must live under /run/",
1229 path.display()
1230 ));
1231 }
1232 if path
1233 .components()
1234 .any(|c| matches!(c, std::path::Component::ParentDir))
1235 {
1236 return Err(format!(
1237 "standalone.socket_path '{}' must not contain '..' segments",
1238 path.display()
1239 ));
1240 }
1241 Ok(())
1242}
1243
1244fn validate_app_name(name: &str) -> Result<(), String> {
1248 let (publisher, app) = if let Some(slash) = name.find('/') {
1249 let (p, rest) = name.split_at(slash);
1250 (Some(p), &rest[1..])
1251 } else {
1252 (None, name)
1253 };
1254
1255 let valid_segment = |s: &str| -> bool {
1256 if s.is_empty() {
1257 return false;
1258 }
1259 let mut chars = s.chars();
1260 let first = chars.next().unwrap();
1261 if !first.is_ascii_lowercase() {
1262 return false;
1263 }
1264 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1265 };
1266
1267 if let Some(pub_name) = publisher {
1268 if !valid_segment(pub_name) {
1269 return Err(format!(
1270 "publisher segment '{}' must match [a-z][a-z0-9-]*",
1271 pub_name
1272 ));
1273 }
1274 }
1275
1276 if !valid_segment(app) {
1277 return Err(format!(
1278 "app name segment '{}' must match [a-z][a-z0-9-]*",
1279 app
1280 ));
1281 }
1282
1283 Ok(())
1284}
1285
1286#[cfg(test)]
1289mod tests {
1290 use super::*;
1291
1292 fn parse_ok(json: &str) -> AppManifest {
1293 AppManifest::from_json(json).expect("should parse")
1294 }
1295
1296 fn parse_err(json: &str) -> String {
1297 AppManifest::from_json(json).expect_err("should fail")
1298 }
1299
1300 #[test]
1303 fn v1_minimal_native() {
1304 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1305 assert_eq!(m.manifest_version, 1);
1306 assert_eq!(m.app_type, AppType::Native);
1307 assert!(m.abi.is_none());
1308 }
1309
1310 #[test]
1311 fn v1_minimal_bun() {
1312 let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
1313 assert_eq!(m.app_type, AppType::Bun);
1314 assert_eq!(m.effective_entrypoint(), "dist/index.js");
1315 }
1316
1317 #[test]
1318 fn v1_no_manifest_version_field_defaults_to_1() {
1319 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1320 assert_eq!(m.manifest_version, 1);
1321 }
1322
1323 #[test]
1324 fn v1_all_optional_fields_missing() {
1325 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1326 assert!(!m.critical);
1327 assert!(m.auto_start);
1328 assert!(!m.has_ui);
1329 assert_eq!(m.ui_path, "dist");
1330 assert!(m.permissions.is_empty());
1331 assert!(m.optional_permissions.is_empty());
1332 assert!(m.governor.is_none());
1335 assert!(m.subscribes.is_empty());
1336 }
1337
1338 #[test]
1339 fn v1_with_permissions_and_provides() {
1340 let json = r#"{
1341 "name": "example",
1342 "version": "1.0.0",
1343 "app_type": "bun",
1344 "permissions": ["core.storage.kv"],
1345 "optional_permissions": ["core.notifications.create"],
1346 "provides": {
1347 "core.example.run": { "description": "Run example job" }
1348 }
1349 }"#;
1350 let m = parse_ok(json);
1351 assert_eq!(m.permissions, vec!["core.storage.kv"]);
1352 assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
1353 assert!(m.provides.contains_key("core.example.run"));
1354 }
1355
1356 #[test]
1359 fn v2_minimal_native() {
1360 let json = r#"{
1361 "manifest_version": 2,
1362 "name": "cron",
1363 "version": "1.0.0",
1364 "app_type": "native",
1365 "abi": "v1",
1366 "entrypoint": "app.so",
1367 "hot_reload": "experimental"
1368 }"#;
1369 let m = parse_ok(json);
1370 assert_eq!(m.manifest_version, 2);
1371 assert_eq!(m.abi, Some(AbiVersion::V1));
1372 assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
1373 assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
1374 }
1375
1376 #[test]
1377 fn v2_minimal_bun_with_capabilities() {
1378 let json = r#"{
1379 "manifest_version": 2,
1380 "name": "example-fullstack",
1381 "version": "1.0.0",
1382 "app_type": "bun",
1383 "abi": "v1",
1384 "entrypoint": "dist/index.js",
1385 "hot_reload": "supported",
1386 "has_ui": true,
1387 "ui_path": "ui/dist",
1388 "capabilities": {
1389 "requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
1390 "provides": []
1391 },
1392 "governor": { "terminable": false, "min_idle_secs": 300 },
1393 "subscribes": ["core.chat.message.received"]
1394 }"#;
1395 let m = parse_ok(json);
1396 assert_eq!(m.manifest_version, 2);
1397 assert_eq!(m.capabilities.requires.len(), 2);
1398 let governor = m.governor.expect("governor block should parse");
1400 assert_eq!(governor.terminable, Some(false));
1401 assert_eq!(governor.min_idle_secs, Some(300));
1402 assert_eq!(m.subscribes, vec!["core.chat.message.received"]);
1403 }
1404
1405 #[test]
1406 fn stage_contract_fixture_parses_with_normalized_requirements() {
1407 let json = include_str!(
1408 "../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
1409 );
1410 let m = parse_ok(json);
1411 assert!(m.has_ui);
1412 assert_eq!(m.resolved_requires().unwrap(), vec!["core.metrics.latest"]);
1413 let ui = m.ui.expect("fixture should declare ui");
1414 assert_eq!(ui.kind, AppUiKind::Stage);
1415 assert_eq!(ui.entry, "ui/main.js");
1416 assert_eq!(ui.nav.unwrap().order, 10);
1417 }
1418
1419 #[test]
1420 fn omitted_ui_keeps_legacy_flags_without_fabricating_a_stage() {
1421 let m = parse_ok(
1422 r#"{"name":"legacy","version":"1.0.0","app_type":"bun","has_ui":true,"ui_path":"ui/dist"}"#,
1423 );
1424 assert!(m.has_ui);
1425 assert_eq!(m.ui_path, "ui/dist");
1426 assert!(m.ui.is_none());
1427 }
1428
1429 #[test]
1430 fn ui_requirements_are_typed_serialized_and_separate_from_backend_requires() {
1431 let manifest = parse_ok(
1432 r#"{
1433 "name":"ui-contract","version":"1.0.0","app_type":"bun",
1434 "requires":["core.cron.register"],
1435 "ui":{
1436 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
1437 "requires":{
1438 "capabilities":["ui.snapshot.v1"],
1439 "queries":["ui.query.v1"],
1440 "streams":["ui.event.v1"]
1441 },
1442 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
1443 }
1444 }"#,
1445 );
1446
1447 assert_eq!(manifest.resolved_requires().unwrap(), vec!["core.cron.register"]);
1448 let ui = manifest.ui.as_ref().expect("ui requirements should parse");
1449 assert_eq!(ui.requires.capabilities, vec!["ui.snapshot.v1"]);
1450 assert_eq!(ui.requires.queries, vec!["ui.query.v1"]);
1451 assert_eq!(ui.requires.streams, vec!["ui.event.v1"]);
1452 assert_eq!(
1453 ui.requires.resolved().unwrap(),
1454 vec!["ui.snapshot.v1", "ui.query.v1", "ui.event.v1"]
1455 );
1456 let serialized = serde_json::to_value(ui).unwrap();
1457 assert_eq!(serialized["requires"]["queries"], serde_json::json!(["ui.query.v1"]));
1458 }
1459
1460 #[test]
1461 fn ui_requirements_reject_blank_entries() {
1462 let error = parse_err(
1463 r#"{
1464 "name":"ui-contract","version":"1.0.0","app_type":"bun",
1465 "ui":{
1466 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
1467 "requires":{"capabilities":[""],"queries":[],"streams":[]},
1468 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
1469 }
1470 }"#,
1471 );
1472 assert!(error.contains("ui.requires entries must not be blank"));
1473 }
1474
1475 #[test]
1476 fn ui_data_namespace_stays_hyphen_only_when_declarations_allow_underscore() {
1477 assert!(validate_app_data_namespace("obs-viewer").is_ok());
1484
1485 let error = validate_app_data_namespace("obs_viewer")
1486 .expect_err("underscore must not be admitted into a storage namespace");
1487 assert!(
1488 error.contains("must match [a-z][a-z0-9-]*"),
1489 "unexpected error: {error}"
1490 );
1491 }
1492
1493 #[test]
1494 fn ui_data_query_capability_does_not_fall_back_to_backend_requires() {
1495 let error = parse_err(
1496 r#"{
1497 "name":"ui-data","version":"1.0.0","app_type":"bun",
1498 "requires":["ui.snapshot.v1"],
1499 "ui":{
1500 "kind":"stage","entry":"ui/main.js","title":"UI data","ui_api":1,
1501 "requires":{"capabilities":[],"queries":[],"streams":[]},
1502 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1503 "data":{
1504 "namespace":"ui-data","offline":"last-known","sync":{"kind":"cursor"},
1505 "queries":[{"name":"ui-data.snapshot.v1","capability":"ui.snapshot.v1","kind":"snapshot"}],
1506 "streams":[]
1507 }
1508 }
1509 }"#,
1510 );
1511 assert!(error.contains("must be declared in requires"));
1512 }
1513
1514 #[test]
1515 fn top_level_and_nested_requires_must_resolve_to_the_same_set() {
1516 let accepted = parse_ok(
1517 r#"{
1518 "name":"aliases","version":"1.0.0","app_type":"bun",
1519 "requires":["core.chat.read","core.chat.read","core.chat.send"],
1520 "capabilities":{"requires":["core.chat.send","core.chat.read"]}
1521 }"#,
1522 );
1523 assert_eq!(
1524 accepted.resolved_requires().unwrap(),
1525 vec!["core.chat.read", "core.chat.send"]
1526 );
1527
1528 let err = parse_err(
1529 r#"{
1530 "name":"aliases","version":"1.0.0","app_type":"bun",
1531 "requires":["core.chat.read"],
1532 "capabilities":{"requires":["core.wallet.pay"]}
1533 }"#,
1534 );
1535 assert!(err.contains("conflicts"), "unexpected error: {err}");
1536 }
1537
1538 #[test]
1539 fn stage_and_widget_ui_kinds_have_distinct_navigation_rules() {
1540 let base = |ui: &str| {
1541 format!(r#"{{"name":"stage","version":"1.0.0","app_type":"bun","ui":{ui}}}"#)
1542 };
1543 let widget = base(
1544 r#"{"kind":"widget","entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1545 );
1546 assert_eq!(
1547 parse_ok(&widget).ui.expect("widget ui").kind,
1548 AppUiKind::Widget
1549 );
1550 let widget_nav = base(
1551 r#"{"kind":"widget","entry":"ui/main.js","title":"Widget","nav":{"section":"default","order":1},"ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1552 );
1553 assert!(parse_err(&widget_nav).contains("must omit nav"));
1554 let api = base(
1555 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":2,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1556 );
1557 assert_eq!(parse_ok(&api).ui.expect("v2 stage ui").ui_api, 2);
1558 let unsupported_api = base(
1559 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":3,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1560 );
1561 assert!(parse_err(&unsupported_api).contains("ui_api"));
1562 let path = base(
1563 r#"{"kind":"stage","entry":"../main.js","title":"Stage","ui_api":1,"integrity":{"../main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1564 );
1565 assert!(parse_err(&path).contains("entry"));
1566 }
1567
1568 #[test]
1569 fn stage_requires_integrity_for_entry_and_icon() {
1570 let missing_entry = r#"{
1571 "name":"stage","version":"1.0.0","app_type":"bun",
1572 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{}}
1573 }"#;
1574 assert!(parse_err(missing_entry).contains("entry"));
1575 let missing_icon = r#"{
1576 "name":"stage","version":"1.0.0","app_type":"bun",
1577 "ui":{"entry":"ui/main.js","icon":"ui/icon.svg","title":"Stage","ui_api":1,
1578 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
1579 }"#;
1580 assert!(parse_err(missing_icon).contains("icon"));
1581 let uppercase = r#"{
1582 "name":"stage","version":"1.0.0","app_type":"bun",
1583 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
1584 "integrity":{"ui/main.js":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}
1585 }"#;
1586 assert!(parse_err(uppercase).contains("lowercase"));
1587 }
1588
1589 #[test]
1590 fn stage_composes_rejects_self_duplicate_and_unsafe_names() {
1591 let manifest = |composes: &str| {
1592 format!(
1593 r#"{{
1594 "name":"stage","version":"1.0.0","app_type":"bun",
1595 "ui":{{"entry":"ui/main.js","title":"Stage","ui_api":1,
1596 "composes":{composes},
1597 "integrity":{{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}}
1598 }}"#
1599 )
1600 };
1601 assert!(parse_err(&manifest(r#"["stage"]"#)).contains("itself"));
1602 assert!(parse_err(&manifest(r#"["chat","chat"]"#)).contains("duplicate"));
1603 assert!(parse_err(&manifest(r#"["../chat"]"#)).contains("invalid"));
1604 }
1605
1606 #[test]
1607 fn v2_missing_abi_is_error() {
1608 let json = r#"{
1609 "manifest_version": 2,
1610 "name": "example",
1611 "version": "1.0.0",
1612 "app_type": "bun"
1613 }"#;
1614 let err = parse_err(json);
1615 assert!(err.contains("abi"), "expected abi error, got: {}", err);
1616 }
1617
1618 #[test]
1621 fn publisher_prefixed_name_accepted() {
1622 let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
1623 assert_eq!(m.name, "alice/weather");
1624 }
1625
1626 #[test]
1627 fn double_slash_name_rejected() {
1628 let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
1629 assert!(!err.is_empty());
1630 }
1631
1632 #[test]
1635 fn name_starting_with_digit_rejected() {
1636 let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
1637 assert!(!err.is_empty());
1638 }
1639
1640 #[test]
1641 fn name_with_uppercase_rejected() {
1642 let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
1643 assert!(!err.is_empty());
1644 }
1645
1646 #[test]
1647 fn empty_name_rejected() {
1648 let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
1649 assert!(!err.is_empty());
1650 }
1651
1652 #[test]
1655 fn path_traversal_double_dot_rejected() {
1656 let json = r#"{
1657 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1658 "app_type": "bun", "abi": "v1",
1659 "entrypoint": "../etc/passwd"
1660 }"#;
1661 let err = parse_err(json);
1662 assert!(err.contains(".."), "expected traversal error, got: {}", err);
1663 }
1664
1665 #[test]
1666 fn path_traversal_encoded_dot_not_decoded() {
1667 let json = r#"{
1669 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1670 "app_type": "bun", "abi": "v1",
1671 "entrypoint": "foo/../bar"
1672 }"#;
1673 let err = parse_err(json);
1674 assert!(!err.is_empty(), "should have failed: {}", err);
1675 }
1676
1677 #[test]
1678 fn absolute_path_rejected() {
1679 let json = r#"{
1680 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1681 "app_type": "bun", "abi": "v1",
1682 "entrypoint": "/usr/bin/sh"
1683 }"#;
1684 let err = parse_err(json);
1685 assert!(
1686 err.contains("absolute"),
1687 "expected absolute error, got: {}",
1688 err
1689 );
1690 }
1691
1692 #[test]
1693 fn shell_metachar_in_path_rejected() {
1694 let json = r#"{
1695 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1696 "app_type": "bun", "abi": "v1",
1697 "entrypoint": "dist/index.js;rm -rf /"
1698 }"#;
1699 let err = parse_err(json);
1700 assert!(!err.is_empty());
1701 }
1702
1703 #[test]
1704 fn valid_nested_path_accepted() {
1705 let json = r#"{
1706 "manifest_version": 2, "name": "my-app", "version": "1.0.0",
1707 "app_type": "bun", "abi": "v1",
1708 "entrypoint": "dist/index.js",
1709 "ui_path": "ui/dist"
1710 }"#;
1711 parse_ok(json);
1712 }
1713
1714 #[test]
1717 fn homepage_https_accepted() {
1718 let json = r#"{
1719 "name": "my-app", "version": "1.0.0", "app_type": "bun",
1720 "homepage": "https://example.com"
1721 }"#;
1722 parse_ok(json);
1723 }
1724
1725 #[test]
1726 fn homepage_javascript_scheme_rejected() {
1727 let json = r#"{
1728 "name": "my-app", "version": "1.0.0", "app_type": "bun",
1729 "homepage": "javascript:alert(1)"
1730 }"#;
1731 let err = parse_err(json);
1732 assert!(
1733 err.contains("scheme"),
1734 "expected scheme error, got: {}",
1735 err
1736 );
1737 }
1738
1739 #[test]
1740 fn homepage_file_scheme_rejected() {
1741 let json = r#"{
1742 "name": "my-app", "version": "1.0.0", "app_type": "bun",
1743 "homepage": "file:///etc/passwd"
1744 }"#;
1745 let err = parse_err(json);
1746 assert!(!err.is_empty());
1747 }
1748
1749 #[test]
1752 fn effective_entrypoint_native_default() {
1753 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1754 assert_eq!(m.effective_entrypoint(), "app.so");
1755 }
1756
1757 #[test]
1758 fn effective_entrypoint_bun_default() {
1759 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1760 assert_eq!(m.effective_entrypoint(), "dist/index.js");
1761 }
1762
1763 #[test]
1764 fn effective_hot_reload_native_default_is_experimental() {
1765 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1766 assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
1767 }
1768
1769 #[test]
1770 fn effective_hot_reload_bun_default_is_supported() {
1771 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1772 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
1773 }
1774
1775 #[test]
1776 fn hot_reload_unsupported_explicit() {
1777 let json = r#"{
1778 "manifest_version": 2, "name": "myapp", "version": "1.0.0",
1779 "app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
1780 }"#;
1781 let m = parse_ok(json);
1782 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
1783 }
1784
1785 #[test]
1788 fn validate_path_simple_valid() {
1789 assert!(validate_manifest_path("dist/index.js").is_ok());
1790 assert!(validate_manifest_path("app.so").is_ok());
1791 assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
1792 assert!(validate_manifest_path("build_output/main").is_ok());
1793 }
1794
1795 #[test]
1796 fn validate_path_empty_rejected() {
1797 assert!(validate_manifest_path("").is_err());
1798 }
1799
1800 #[test]
1801 fn validate_path_absolute_rejected() {
1802 assert!(validate_manifest_path("/usr/bin/sh").is_err());
1803 }
1804
1805 #[test]
1806 fn validate_path_double_dot_segment_rejected() {
1807 assert!(validate_manifest_path("foo/../bar").is_err());
1808 assert!(validate_manifest_path("../etc/passwd").is_err());
1809 }
1810
1811 #[test]
1812 fn validate_path_leading_dot_rejected() {
1813 assert!(validate_manifest_path(".hidden").is_err());
1814 }
1815
1816 #[test]
1817 fn validate_path_null_byte_rejected() {
1818 let path = "foo\0bar";
1820 assert!(validate_manifest_path(path).is_err());
1821 }
1822
1823 #[test]
1824 fn standalone_socket_path_development_override_is_explicit_and_pure() {
1825 let path = std::path::Path::new("/tmp/node-app/example.sock");
1826 assert!(validate_standalone_socket_path(path).is_err());
1827 assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
1828 assert!(validate_standalone_socket_path_with_policy(
1829 std::path::Path::new("/tmp/node-app/../escape.sock"),
1830 true,
1831 )
1832 .is_err());
1833 }
1834
1835 #[test]
1838 fn manifest_capabilities_defaults_to_empty() {
1839 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1840 assert!(m.capabilities.requires.is_empty());
1841 assert!(m.capabilities.provides.is_empty());
1842 }
1843
1844 #[test]
1847 fn resolved_capability_provides_v1_only() {
1848 let json = r#"{
1849 "name": "example", "version": "1.0.0", "app_type": "bun",
1850 "provides": { "core.example.run": { "description": "Run example job" } }
1851 }"#;
1852 let m = parse_ok(json);
1853 let out = m.resolved_capability_provides();
1854 assert_eq!(out.len(), 1);
1855 assert_eq!(
1856 out.get("core.example.run").unwrap().description,
1857 "Run example job"
1858 );
1859 }
1860
1861 #[test]
1862 fn resolved_capability_provides_v2_names_get_blank_declaration() {
1863 let json = r#"{
1864 "manifest_version": 2, "name": "example", "version": "1.0.0",
1865 "app_type": "bun", "abi": "v1",
1866 "capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
1867 }"#;
1868 let m = parse_ok(json);
1869 let out = m.resolved_capability_provides();
1870 assert_eq!(out.len(), 2);
1871 assert_eq!(out.get("core.example.run").unwrap().description, "");
1872 assert!(out.get("core.example.run").unwrap().schema.is_none());
1873 assert!(out.contains_key("core.example.other"));
1875 assert!(!out.contains_key("core.example.other:extra"));
1876 }
1877
1878 #[test]
1879 fn resolved_capability_provides_v1_wins_on_conflict() {
1880 let json = r#"{
1881 "manifest_version": 2, "name": "example", "version": "1.0.0",
1882 "app_type": "bun", "abi": "v1",
1883 "provides": { "core.example.run": { "description": "v1 wins" } },
1884 "capabilities": { "requires": [], "provides": ["core.example.run"] }
1885 }"#;
1886 let m = parse_ok(json);
1887 let out = m.resolved_capability_provides();
1888 assert_eq!(out.len(), 1);
1889 assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
1890 }
1891
1892 #[test]
1893 fn resolved_capability_provides_blank_v2_name_skipped() {
1894 let json = r#"{
1895 "manifest_version": 2, "name": "example", "version": "1.0.0",
1896 "app_type": "bun", "abi": "v1",
1897 "capabilities": { "requires": [], "provides": [" ", "core.example.run"] }
1898 }"#;
1899 let m = parse_ok(json);
1900 let out = m.resolved_capability_provides();
1901 assert_eq!(out.len(), 1);
1902 assert!(out.contains_key("core.example.run"));
1903 }
1904
1905 #[test]
1906 fn resolved_capability_provides_empty_manifest_yields_empty_map() {
1907 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1908 assert!(m.resolved_capability_provides().is_empty());
1909 }
1910
1911 #[test]
1914 fn abi_v1_is_supported() {
1915 assert!(AbiVersion::V1.is_supported());
1916 }
1917
1918 #[test]
1921 fn app_tier_display() {
1922 assert_eq!(AppTier::FirstParty.to_string(), "first_party");
1923 assert_eq!(AppTier::Optional.to_string(), "optional");
1924 assert_eq!(AppTier::Development.to_string(), "development");
1925 }
1926
1927 #[test]
1928 fn app_tier_serde_roundtrip() {
1929 for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
1931 let json = serde_json::to_string(&tier).unwrap();
1932 let back: AppTier = serde_json::from_str(&json).unwrap();
1933 assert_eq!(
1934 tier, back,
1935 "roundtrip failed for {:?}: serialized as {}",
1936 tier, json
1937 );
1938 }
1939 assert_eq!(
1940 serde_json::to_string(&AppTier::Development).unwrap(),
1941 "\"development\""
1942 );
1943 }
1944
1945 #[test]
1948 fn app_type_display() {
1949 assert_eq!(AppType::Native.to_string(), "native");
1950 assert_eq!(AppType::Bun.to_string(), "bun");
1951 assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
1952 }
1953
1954 #[test]
1955 fn platform_runtime_is_a_supported_packaging_type() {
1956 let manifest: AppManifest = serde_json::from_value(serde_json::json!({
1957 "manifest_version": 2,
1958 "abi": "v1",
1959 "name": "bun-runtime",
1960 "version": "1.0.0",
1961 "app_type": "platform-runtime",
1962 "entrypoint": "bun"
1963 }))
1964 .expect("platform runtime manifest should parse");
1965
1966 assert_eq!(manifest.app_type, AppType::PlatformRuntime);
1967 assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
1968 }
1969
1970 #[test]
1973 fn governor_manifest_memory_budget_defaults_to_none() {
1974 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
1975 assert_eq!(parsed.memory_budget_kb, None);
1976 }
1977
1978 #[test]
1979 fn governor_manifest_parses_declared_memory_budget() {
1980 let parsed: GovernorManifest =
1981 serde_json::from_str(r#"{"memory_budget_kb": 40960}"#).unwrap();
1982 assert_eq!(parsed.memory_budget_kb, Some(40_960));
1983 }
1984}