Skip to main content

node_app_manifest/
manifest.rs

1//! App manifest domain entity — unified v1/v2 schema.
2//!
3//! This module defines the canonical `AppManifest` used by the Node daemon to
4//! discover, load, and validate mini apps. It is a backward-compatible superset
5//! of the existing `PerAppManifest` (now promoted from `apps/server`).
6//!
7//! # Schema version detection
8//!
9//! - **v1** (legacy): no `manifest_version` field → `manifest_version = 1`.
10//!   All v2-only fields default to their v1-equivalent values. Zero existing
11//!   app manifests are invalidated.
12//! - **v2** (extended): `manifest_version = 2`. Adds `abi`, `entrypoint`,
13//!   `hot_reload`, and the typed `capabilities` block. Requires `abi` to be
14//!   present when `manifest_version == 2`.
15//!
16//! # Path-safety (SEC-H3)
17//!
18//! `entrypoint` and `ui_path` are validated at parse time:
19//! 1. Matches regex `^[a-zA-Z0-9_][a-zA-Z0-9_./-]*$`
20//! 2. Contains no `..` segment
21//! 3. Does not begin with `/`
22//!
23//! The canonicalize-inside-install-dir check (step 4) is performed by
24//! `tier_validator.rs` at load time because the install directory is not known
25//! until the daemon resolves the path.
26
27use serde::{Deserialize, Serialize};
28use std::collections::HashMap;
29
30// ── Enums ────────────────────────────────────────────────────────────────────
31
32/// App execution model — determines how the daemon loads and isolates the app.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum AppType {
36    /// In-process cdylib loaded via dlopen. First-party path only (SEC-H1).
37    Native,
38    /// Isolated subprocess managed by the Bun runtime.
39    Bun,
40    /// Independent systemd-managed service that owns its own Unix domain socket.
41    /// The daemon does not start or supervise the process; it only routes
42    /// capability invocations to the app's socket as JSON-RPC 2.0.
43    /// Requires a `standalone.socket_path` when the manifest declares any
44    /// `provides` / `capabilities.provides` entries.
45    Standalone,
46    /// Packaging-only runtime dependency (for example the shared Bun runtime).
47    /// It is installed and versioned like an app package but is never loaded,
48    /// registered as a capability provider, or hot-reloaded as an app.
49    #[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/// Trust/distribution tier — derived at load time from the install path
71/// AND (per FR-028 cycle 4) the manifest sidecar's GPG signature.
72///
73/// This is **not** stored in the manifest; it is computed by `tier_validator`.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum AppTier {
77    /// App installed at the bundled path (`/usr/share/node/builtin-apps/`)
78    /// OR at the apt path with a manifest sidecar signed by a node project key.
79    /// May be `Native` or `Bun`. Highest trust.
80    FirstParty,
81    /// App installed at the optional apt path (`/usr/lib/node/apps/`) with
82    /// no/invalid project signature. MUST be `Bun`; `Native` at this tier
83    /// triggers `TierError` (FR-028).
84    Optional,
85    /// App loaded from a developer's local dev directory (`NODE_DEV_APPS_DIR`),
86    /// via `node-app-build dev` or manual sideload. Bypasses signature checks
87    /// because the dev directory is owned by the developer (security gate is
88    /// the file-system path: only the dev user can write to it). Permitted
89    /// for `Native` apps so cdylib developers can iterate without per-build
90    /// GPG signing.
91    ///
92    /// Daemon logs every Development-tier load at `info!` so operators of a
93    /// real node can see when a non-prod app is active. UI badges this tier
94    /// distinctly (amber/red, never green).
95    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/// Host ABI compatibility version declared by the app.
115///
116/// The runtime's currently supported set is `[V1]`. Apps declaring an
117/// unsupported version are rejected with `AbiIncompatible` (FR-018).
118#[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    /// Returns true if this ABI version is supported by the current runtime.
132    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/// How in-process (native) app reload is expected to behave.
144///
145/// Per research.md §R10, native hot-reload is inherently unreliable due to
146/// `dlclose` semantics. The manifest field sets correct user expectations.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub enum HotReloadKind {
150    /// Reload is reliable (Bun subprocess restart). Default for `Bun` apps.
151    Supported,
152    /// Reload attempted but may fail; expect `RequiresRestart` on failure.
153    /// Default for `Native` apps (FR-024).
154    Experimental,
155    /// App must be restarted to pick up changes.
156    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            // Standalone apps are restarted by systemd, not the daemon —
165            // from the daemon's perspective they are never hot-reloaded.
166            AppType::Standalone => HotReloadKind::Unsupported,
167            AppType::PlatformRuntime => HotReloadKind::Unsupported,
168        }
169    }
170}
171
172// ── Sub-types ─────────────────────────────────────────────────────────────────
173
174/// Capability declarations from the v2 manifest `capabilities` block.
175///
176/// Semantic equivalent of the existing `permissions` + `provides` fields;
177/// v2 manifests may use either or both (backward compat preserved).
178#[derive(Debug, Clone, Default, Serialize, Deserialize)]
179pub struct ManifestCapabilities {
180    /// Capabilities this app requests from the host or other apps.
181    /// Format: `"core.lightning.payment.send:max=1000sat/day"` (see §1.2).
182    #[serde(default)]
183    pub requires: Vec<String>,
184
185    /// Capabilities this app provides to other apps.
186    /// Format: `"core.cron.register"`.
187    #[serde(default)]
188    pub provides: Vec<String>,
189}
190
191/// A single scope provided by an app (existing v1 model, preserved verbatim).
192#[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/// Declarative per-endpoint access policy (existing v1 model, preserved verbatim).
200#[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/// Capability provider declaration (existing v1 model, preserved verbatim).
208#[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/// Configuration for `AppType::Standalone` apps.
217///
218/// Carried only by manifests whose `app_type == "standalone"`. The daemon uses
219/// `socket_path` to route capability invocations as line-delimited JSON-RPC 2.0
220/// over the standalone daemon's own Unix domain socket.
221///
222/// Path-safety rules (validated by `AppManifest::validate`):
223/// - Absolute path.
224/// - Lives under `/run/`.
225/// - No `..` segments.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct StandaloneConfig {
228    pub socket_path: std::path::PathBuf,
229}
230
231// ── Path-safety helpers ───────────────────────────────────────────────────────
232
233/// Validate a relative file path declared in a manifest (`entrypoint`, `ui_path`).
234///
235/// Rules (SEC-H3):
236/// 1. Matches `^[a-zA-Z0-9_][a-zA-Z0-9_./-]*$` — rejects shell metacharacters,
237///    leading `.`, leading `/`, etc.
238/// 2. No `..` segment anywhere.
239/// 3. Does not begin with `/` (absolute paths).
240///
241/// Returns `Ok(())` if valid, `Err(reason)` describing the violation.
242pub fn validate_manifest_path(path: &str) -> Result<(), String> {
243    if path.is_empty() {
244        return Err("path must not be empty".to_string());
245    }
246
247    // Rule 3: no absolute paths
248    if path.starts_with('/') {
249        return Err(format!(
250            "path '{}' must not be absolute (starts with /)",
251            path
252        ));
253    }
254
255    // Rule 1: allowed character set
256    // ^[a-zA-Z0-9_][a-zA-Z0-9_./-]*$
257    let first = path.chars().next().unwrap();
258    if !first.is_ascii_alphanumeric() && first != '_' {
259        return Err(format!(
260            "path '{}' must begin with an alphanumeric character or underscore",
261            path
262        ));
263    }
264    for ch in path.chars().skip(1) {
265        if !ch.is_ascii_alphanumeric() && !matches!(ch, '_' | '.' | '/' | '-') {
266            return Err(format!(
267                "path '{}' contains disallowed character '{}'",
268                path, ch
269            ));
270        }
271    }
272
273    // Rule 2: no `..` segment
274    for segment in path.split('/') {
275        if segment == ".." {
276            return Err(format!(
277                "path '{}' contains a '..' segment (path traversal rejected)",
278                path
279            ));
280        }
281    }
282
283    Ok(())
284}
285
286// ── AppManifest ───────────────────────────────────────────────────────────────
287
288/// Canonical manifest entity — unified v1/v2 format.
289///
290/// Deserializes both old (v1, no `manifest_version`) and new (v2) manifests.
291/// All v2-only fields use `#[serde(default)]` so that v1 manifests parse
292/// correctly without any field changes.
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct AppManifest {
295    /// Schema version. Absent or 1 = legacy v1; 2 = extended v2.
296    #[serde(default = "default_manifest_version", rename = "manifest_version")]
297    pub manifest_version: u8,
298
299    pub name: String,
300    pub version: String,
301
302    #[serde(default = "default_app_type_native")]
303    pub app_type: AppType,
304
305    #[serde(default)]
306    pub description: String,
307
308    // ── v2-only additions (all optional, v1-compatible defaults) ─────────────
309    /// Host ABI compatibility version. Required when `manifest_version == 2`.
310    pub abi: Option<AbiVersion>,
311
312    /// Payload entry point relative to the app directory.
313    /// Default: `app.so` for Native, `dist/index.js` for Bun.
314    pub entrypoint: Option<String>,
315
316    /// Hot-reload behaviour classification.
317    /// Default: `experimental` for Native, `supported` for Bun.
318    pub hot_reload: Option<HotReloadKind>,
319
320    // ── Existing v1 fields (preserved verbatim — DO NOT RENAME) ──────────────
321    #[serde(default)]
322    pub critical: bool,
323
324    #[serde(
325        default = "default_auto_start",
326        deserialize_with = "deserialize_auto_start"
327    )]
328    pub auto_start: bool,
329
330    #[serde(default)]
331    pub has_ui: bool,
332
333    #[serde(default = "default_ui_path")]
334    pub ui_path: String,
335
336    #[serde(default)]
337    pub permissions: Vec<String>,
338
339    #[serde(default)]
340    pub optional_permissions: Vec<String>,
341
342    #[serde(default)]
343    pub provides_scopes: Vec<ProvidedScope>,
344
345    #[serde(default)]
346    pub endpoint_policies: Vec<EndpointPolicy>,
347
348    #[serde(default)]
349    pub capability_scopes: HashMap<String, String>,
350
351    #[serde(default)]
352    pub provides: HashMap<String, ProvidedCapability>,
353
354    // ── v2 capabilities block (semantic alias for permissions + provides) ─────
355    #[serde(default)]
356    pub capabilities: ManifestCapabilities,
357
358    // ── Optional metadata fields ──────────────────────────────────────────────
359    #[serde(default)]
360    pub author: Option<String>,
361
362    #[serde(default)]
363    pub homepage: Option<String>,
364
365    #[serde(default)]
366    pub depends_on: Option<Vec<String>>,
367
368    #[serde(default)]
369    pub boot_priority: Option<u32>,
370
371    /// Required when `app_type == "standalone"` and the manifest declares any
372    /// `provides` / `capabilities.provides` entries. Carries the Unix domain
373    /// socket path the daemon dispatches capability calls to.
374    #[serde(default)]
375    pub standalone: Option<StandaloneConfig>,
376
377    /// Optional TCP-binding block — feature 470 (port registry).
378    /// Absence means the app does not bind a TCP port the registry manages.
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub tcp: Option<TcpManifest>,
381}
382
383/// TCP port preferences for standalone apps that bind their own port.
384/// Consumed by the port registry (`system/server/src/services/port_registry/`)
385/// at install time.
386#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
387pub struct TcpManifest {
388    /// The TCP port the app would like to bind. Honored when free;
389    /// otherwise the registry assigns the next free port from the pool
390    /// (default 7000–7099). Absent → registry picks any free pool slot.
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub preferred_port: Option<u16>,
393
394    /// When `true`, the platform UI shell builds iframe URLs as direct LAN
395    /// connections to the assigned port rather than routing via the
396    /// `/api/v2/node-apps/{name}/ui/` reverse-proxy. Intended only for apps
397    /// that must outlive a platform restart (e.g. OTA self-upgrade). Remote
398    /// users may see a degraded experience — owned by the consuming app's UI,
399    /// not this spec (see `specs/470-port-registry/spec.md` Clarifications Q5b).
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub direct_bind: Option<bool>,
402}
403
404fn default_manifest_version() -> u8 {
405    1
406}
407
408fn default_auto_start() -> bool {
409    true
410}
411
412/// Deserialize `auto_start` from either a bool (manifest v1) or a load-mode
413/// string (v2, e.g. `"lazy"`/`"eager"`/`"active"`). Eager-start modes map to
414/// `true`; `"lazy"` and other on-demand/inactive states map to `false` (the app
415/// is started on first capability use, not at boot). This keeps both manifest
416/// schema generations parseable by `AppManifest::from_json`.
417fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
418where
419    D: serde::Deserializer<'de>,
420{
421    #[derive(Deserialize)]
422    #[serde(untagged)]
423    enum BoolOrStr {
424        Bool(bool),
425        Str(String),
426    }
427    Ok(match BoolOrStr::deserialize(deserializer)? {
428        BoolOrStr::Bool(b) => b,
429        BoolOrStr::Str(s) => matches!(
430            s.trim().to_ascii_lowercase().as_str(),
431            "true" | "eager" | "active" | "auto" | "on" | "1"
432        ),
433    })
434}
435
436fn default_ui_path() -> String {
437    "dist".to_string()
438}
439
440fn default_app_type_native() -> AppType {
441    AppType::Native
442}
443
444impl AppManifest {
445    /// Returns the effective `HotReloadKind` — explicit field or the default
446    /// for the app type.
447    pub fn effective_hot_reload(&self) -> HotReloadKind {
448        self.hot_reload
449            .unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
450    }
451
452    /// Returns the effective entrypoint — explicit field or the type-specific default.
453    ///
454    /// Standalone apps have no daemon-managed entrypoint (systemd owns the
455    /// lifecycle); the empty string signals "not applicable".
456    pub fn effective_entrypoint(&self) -> &str {
457        if let Some(ref ep) = self.entrypoint {
458            ep.as_str()
459        } else {
460            match self.app_type {
461                AppType::Native => "app.so",
462                AppType::Bun => "dist/index.js",
463                AppType::Standalone => "",
464                AppType::PlatformRuntime => "bun",
465            }
466        }
467    }
468
469    /// True iff this manifest declares at least one capability provider
470    /// (via either the v1 `provides` map or the v2 `capabilities.provides` list).
471    pub fn has_capability_providers(&self) -> bool {
472        !self.provides.is_empty() || !self.capabilities.provides.is_empty()
473    }
474
475    /// Merges the v1 `provides` map and the v2 `capabilities.provides` name
476    /// list into a single capability→declaration map (composition-root
477    /// cleanup Round 4 T28 — extracted from
478    /// `control_ipc::handlers::handle_app_register_standalone`, which uses
479    /// this to shape a standalone app's declared providers for capability
480    /// registration).
481    ///
482    /// - v1 entries (the `provides` map) carry their real
483    ///   description/schema and always win on a name conflict.
484    /// - v2-only names (declared only via `capabilities.provides`, format
485    ///   `"name"` or `"name:extra"` — only the part before the first `:` is
486    ///   used) get a blank declaration, inserted only if the name is not
487    ///   already present from v1. Blank/whitespace-only names are skipped.
488    pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
489        let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
490        for raw in &self.capabilities.provides {
491            let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
492            if name.is_empty() {
493                continue;
494            }
495            out.entry(name).or_insert(ProvidedCapability {
496                description: String::new(),
497                schema: None,
498            });
499        }
500        out
501    }
502
503    /// Validate the manifest for structural correctness.
504    ///
505    /// Returns `Ok(())` on success, or a human-readable error string.
506    /// Called by the manifest parser after deserialization.
507    pub fn validate(&self) -> Result<(), String> {
508        self.validate_with_socket_path_policy(false)
509    }
510
511    /// Validate this manifest with an explicit standalone socket-path policy.
512    ///
513    /// Runtime adapters may opt into non-`/run` paths for development without
514    /// making the domain model read process configuration.
515    pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
516        // v2 requires abi field
517        if self.manifest_version == 2 && self.abi.is_none() {
518            return Err("manifest_version 2 requires an 'abi' field".to_string());
519        }
520
521        // Name validation: ^[a-z][a-z0-9-]*(/([a-z][a-z0-9-]*))?$
522        // (publisher/name form accepted but not yet semantically used — FR-019)
523        validate_app_name(&self.name)?;
524
525        // Path-safety on entrypoint and ui_path
526        if let Some(ref ep) = self.entrypoint {
527            validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
528        }
529        // ui_path is only meaningful when has_ui is true, but validate always
530        if !self.ui_path.is_empty() && self.ui_path != "dist" {
531            validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
532        }
533
534        // Homepage scheme validation (if present)
535        if let Some(ref hp) = self.homepage {
536            if !hp.starts_with("https://") && !hp.starts_with("http://") {
537                return Err(format!(
538                    "homepage '{}' must use https:// or http:// scheme",
539                    hp
540                ));
541            }
542        }
543
544        // Standalone-app rules:
545        // - When `app_type == "standalone"` AND the manifest declares any
546        //   capability providers, `standalone.socket_path` is required and
547        //   must be an absolute path under `/run/` with no `..` segments.
548        // - Non-standalone manifests MUST NOT carry a `standalone` block
549        //   (rejected to surface accidental schema misuse).
550        match self.app_type {
551            AppType::Standalone => {
552                if self.has_capability_providers() {
553                    let cfg = self.standalone.as_ref().ok_or_else(|| {
554                        "standalone apps that declare 'provides' require a \
555                         'standalone.socket_path' field"
556                            .to_string()
557                    })?;
558                    validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
559                }
560            }
561            AppType::Native | AppType::Bun | AppType::PlatformRuntime => {
562                if self.standalone.is_some() {
563                    return Err(format!(
564                        "'standalone' block is only valid when app_type == 'standalone' \
565                         (found app_type='{}')",
566                        self.app_type
567                    ));
568                }
569            }
570        }
571
572        if self.app_type == AppType::PlatformRuntime
573            && !self.resolved_capability_provides().is_empty()
574        {
575            return Err(
576                "platform-runtime packages cannot provide runtime capabilities".to_string(),
577            );
578        }
579
580        Ok(())
581    }
582
583    /// Parse from a JSON string, validate, and return the manifest.
584    pub fn from_json(json: &str) -> Result<Self, String> {
585        Self::from_json_with_socket_path_policy(json, false)
586    }
587
588    /// Parse and validate with an explicit standalone socket-path policy.
589    pub fn from_json_with_socket_path_policy(
590        json: &str,
591        allow_non_run: bool,
592    ) -> Result<Self, String> {
593        let manifest: Self =
594            serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
595        manifest.validate_with_socket_path_policy(allow_non_run)?;
596        Ok(manifest)
597    }
598}
599
600/// Validate a `StandaloneConfig::socket_path`.
601///
602/// Rules:
603/// 1. Absolute path (starts with `/`).
604/// 2. Lives under `/run/` (rejects `/etc/...`, `/tmp/...`, etc. — pins the
605///    socket to a tmpfs path predictably writable by the standalone daemon).
606///    Runtime adapters can explicitly bypass this restriction for development.
607/// 3. No `..` segments anywhere in the path.
608pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
609    validate_standalone_socket_path_with_policy(path, false)
610}
611
612/// Validate a standalone socket path with an explicit runtime policy.
613pub fn validate_standalone_socket_path_with_policy(
614    path: &std::path::Path,
615    allow_non_run: bool,
616) -> Result<(), String> {
617    if !path.is_absolute() {
618        return Err(format!(
619            "standalone.socket_path '{}' must be absolute",
620            path.display()
621        ));
622    }
623    if !allow_non_run && !path.starts_with("/run/") {
624        return Err(format!(
625            "standalone.socket_path '{}' must live under /run/",
626            path.display()
627        ));
628    }
629    if path
630        .components()
631        .any(|c| matches!(c, std::path::Component::ParentDir))
632    {
633        return Err(format!(
634            "standalone.socket_path '{}' must not contain '..' segments",
635            path.display()
636        ));
637    }
638    Ok(())
639}
640
641/// Validate an app name string.
642///
643/// Accepts `app-name` (simple) and `publisher/app-name` (publisher-prefixed, FR-019).
644fn validate_app_name(name: &str) -> Result<(), String> {
645    let (publisher, app) = if let Some(slash) = name.find('/') {
646        let (p, rest) = name.split_at(slash);
647        (Some(p), &rest[1..])
648    } else {
649        (None, name)
650    };
651
652    let valid_segment = |s: &str| -> bool {
653        if s.is_empty() {
654            return false;
655        }
656        let mut chars = s.chars();
657        let first = chars.next().unwrap();
658        if !first.is_ascii_lowercase() {
659            return false;
660        }
661        chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
662    };
663
664    if let Some(pub_name) = publisher {
665        if !valid_segment(pub_name) {
666            return Err(format!(
667                "publisher segment '{}' must match [a-z][a-z0-9-]*",
668                pub_name
669            ));
670        }
671    }
672
673    if !valid_segment(app) {
674        return Err(format!(
675            "app name segment '{}' must match [a-z][a-z0-9-]*",
676            app
677        ));
678    }
679
680    Ok(())
681}
682
683// ── Tests ─────────────────────────────────────────────────────────────────────
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688
689    fn parse_ok(json: &str) -> AppManifest {
690        AppManifest::from_json(json).expect("should parse")
691    }
692
693    fn parse_err(json: &str) -> String {
694        AppManifest::from_json(json).expect_err("should fail")
695    }
696
697    // ── v1 manifests ──────────────────────────────────────────────────────────
698
699    #[test]
700    fn v1_minimal_native() {
701        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
702        assert_eq!(m.manifest_version, 1);
703        assert_eq!(m.app_type, AppType::Native);
704        assert!(m.abi.is_none());
705    }
706
707    #[test]
708    fn v1_minimal_bun() {
709        let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
710        assert_eq!(m.app_type, AppType::Bun);
711        assert_eq!(m.effective_entrypoint(), "dist/index.js");
712    }
713
714    #[test]
715    fn v1_no_manifest_version_field_defaults_to_1() {
716        let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
717        assert_eq!(m.manifest_version, 1);
718    }
719
720    #[test]
721    fn v1_all_optional_fields_missing() {
722        let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
723        assert!(!m.critical);
724        assert!(m.auto_start);
725        assert!(!m.has_ui);
726        assert_eq!(m.ui_path, "dist");
727        assert!(m.permissions.is_empty());
728        assert!(m.optional_permissions.is_empty());
729    }
730
731    #[test]
732    fn v1_with_permissions_and_provides() {
733        let json = r#"{
734            "name": "example",
735            "version": "1.0.0",
736            "app_type": "bun",
737            "permissions": ["core.storage.kv"],
738            "optional_permissions": ["core.notifications.create"],
739            "provides": {
740                "core.example.run": { "description": "Run example job" }
741            }
742        }"#;
743        let m = parse_ok(json);
744        assert_eq!(m.permissions, vec!["core.storage.kv"]);
745        assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
746        assert!(m.provides.contains_key("core.example.run"));
747    }
748
749    // ── v2 manifests ──────────────────────────────────────────────────────────
750
751    #[test]
752    fn v2_minimal_native() {
753        let json = r#"{
754            "manifest_version": 2,
755            "name": "cron",
756            "version": "1.0.0",
757            "app_type": "native",
758            "abi": "v1",
759            "entrypoint": "app.so",
760            "hot_reload": "experimental"
761        }"#;
762        let m = parse_ok(json);
763        assert_eq!(m.manifest_version, 2);
764        assert_eq!(m.abi, Some(AbiVersion::V1));
765        assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
766        assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
767    }
768
769    #[test]
770    fn v2_minimal_bun_with_capabilities() {
771        let json = r#"{
772            "manifest_version": 2,
773            "name": "example-fullstack",
774            "version": "1.0.0",
775            "app_type": "bun",
776            "abi": "v1",
777            "entrypoint": "dist/index.js",
778            "hot_reload": "supported",
779            "has_ui": true,
780            "ui_path": "ui/dist",
781            "capabilities": {
782                "requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
783                "provides": []
784            }
785        }"#;
786        let m = parse_ok(json);
787        assert_eq!(m.manifest_version, 2);
788        assert_eq!(m.capabilities.requires.len(), 2);
789    }
790
791    #[test]
792    fn v2_missing_abi_is_error() {
793        let json = r#"{
794            "manifest_version": 2,
795            "name": "example",
796            "version": "1.0.0",
797            "app_type": "bun"
798        }"#;
799        let err = parse_err(json);
800        assert!(err.contains("abi"), "expected abi error, got: {}", err);
801    }
802
803    // ── Publisher-prefixed name (FR-019) ──────────────────────────────────────
804
805    #[test]
806    fn publisher_prefixed_name_accepted() {
807        let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
808        assert_eq!(m.name, "alice/weather");
809    }
810
811    #[test]
812    fn double_slash_name_rejected() {
813        let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
814        assert!(!err.is_empty());
815    }
816
817    // ── Malformed names ───────────────────────────────────────────────────────
818
819    #[test]
820    fn name_starting_with_digit_rejected() {
821        let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
822        assert!(!err.is_empty());
823    }
824
825    #[test]
826    fn name_with_uppercase_rejected() {
827        let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
828        assert!(!err.is_empty());
829    }
830
831    #[test]
832    fn empty_name_rejected() {
833        let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
834        assert!(!err.is_empty());
835    }
836
837    // ── Path-safety (SEC-H3) ─────────────────────────────────────────────────
838
839    #[test]
840    fn path_traversal_double_dot_rejected() {
841        let json = r#"{
842            "manifest_version": 2, "name": "evil", "version": "1.0.0",
843            "app_type": "bun", "abi": "v1",
844            "entrypoint": "../etc/passwd"
845        }"#;
846        let err = parse_err(json);
847        assert!(err.contains(".."), "expected traversal error, got: {}", err);
848    }
849
850    #[test]
851    fn path_traversal_encoded_dot_not_decoded() {
852        // The regex rejects '%' so encoded traversal fails at char check
853        let json = r#"{
854            "manifest_version": 2, "name": "evil", "version": "1.0.0",
855            "app_type": "bun", "abi": "v1",
856            "entrypoint": "foo/../bar"
857        }"#;
858        let err = parse_err(json);
859        assert!(!err.is_empty(), "should have failed: {}", err);
860    }
861
862    #[test]
863    fn absolute_path_rejected() {
864        let json = r#"{
865            "manifest_version": 2, "name": "evil", "version": "1.0.0",
866            "app_type": "bun", "abi": "v1",
867            "entrypoint": "/usr/bin/sh"
868        }"#;
869        let err = parse_err(json);
870        assert!(
871            err.contains("absolute"),
872            "expected absolute error, got: {}",
873            err
874        );
875    }
876
877    #[test]
878    fn shell_metachar_in_path_rejected() {
879        let json = r#"{
880            "manifest_version": 2, "name": "evil", "version": "1.0.0",
881            "app_type": "bun", "abi": "v1",
882            "entrypoint": "dist/index.js;rm -rf /"
883        }"#;
884        let err = parse_err(json);
885        assert!(!err.is_empty());
886    }
887
888    #[test]
889    fn valid_nested_path_accepted() {
890        let json = r#"{
891            "manifest_version": 2, "name": "my-app", "version": "1.0.0",
892            "app_type": "bun", "abi": "v1",
893            "entrypoint": "dist/index.js",
894            "ui_path": "ui/dist"
895        }"#;
896        parse_ok(json);
897    }
898
899    // ── Homepage scheme ───────────────────────────────────────────────────────
900
901    #[test]
902    fn homepage_https_accepted() {
903        let json = r#"{
904            "name": "my-app", "version": "1.0.0", "app_type": "bun",
905            "homepage": "https://example.com"
906        }"#;
907        parse_ok(json);
908    }
909
910    #[test]
911    fn homepage_javascript_scheme_rejected() {
912        let json = r#"{
913            "name": "my-app", "version": "1.0.0", "app_type": "bun",
914            "homepage": "javascript:alert(1)"
915        }"#;
916        let err = parse_err(json);
917        assert!(
918            err.contains("scheme"),
919            "expected scheme error, got: {}",
920            err
921        );
922    }
923
924    #[test]
925    fn homepage_file_scheme_rejected() {
926        let json = r#"{
927            "name": "my-app", "version": "1.0.0", "app_type": "bun",
928            "homepage": "file:///etc/passwd"
929        }"#;
930        let err = parse_err(json);
931        assert!(!err.is_empty());
932    }
933
934    // ── Effective defaults ────────────────────────────────────────────────────
935
936    #[test]
937    fn effective_entrypoint_native_default() {
938        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
939        assert_eq!(m.effective_entrypoint(), "app.so");
940    }
941
942    #[test]
943    fn effective_entrypoint_bun_default() {
944        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
945        assert_eq!(m.effective_entrypoint(), "dist/index.js");
946    }
947
948    #[test]
949    fn effective_hot_reload_native_default_is_experimental() {
950        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
951        assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
952    }
953
954    #[test]
955    fn effective_hot_reload_bun_default_is_supported() {
956        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
957        assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
958    }
959
960    #[test]
961    fn hot_reload_unsupported_explicit() {
962        let json = r#"{
963            "manifest_version": 2, "name": "myapp", "version": "1.0.0",
964            "app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
965        }"#;
966        let m = parse_ok(json);
967        assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
968    }
969
970    // ── validate_manifest_path unit tests ─────────────────────────────────────
971
972    #[test]
973    fn validate_path_simple_valid() {
974        assert!(validate_manifest_path("dist/index.js").is_ok());
975        assert!(validate_manifest_path("app.so").is_ok());
976        assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
977        assert!(validate_manifest_path("build_output/main").is_ok());
978    }
979
980    #[test]
981    fn validate_path_empty_rejected() {
982        assert!(validate_manifest_path("").is_err());
983    }
984
985    #[test]
986    fn validate_path_absolute_rejected() {
987        assert!(validate_manifest_path("/usr/bin/sh").is_err());
988    }
989
990    #[test]
991    fn validate_path_double_dot_segment_rejected() {
992        assert!(validate_manifest_path("foo/../bar").is_err());
993        assert!(validate_manifest_path("../etc/passwd").is_err());
994    }
995
996    #[test]
997    fn validate_path_leading_dot_rejected() {
998        assert!(validate_manifest_path(".hidden").is_err());
999    }
1000
1001    #[test]
1002    fn validate_path_null_byte_rejected() {
1003        // null byte is non-ASCII, rejected by char check
1004        let path = "foo\0bar";
1005        assert!(validate_manifest_path(path).is_err());
1006    }
1007
1008    #[test]
1009    fn standalone_socket_path_development_override_is_explicit_and_pure() {
1010        let path = std::path::Path::new("/tmp/node-app/example.sock");
1011        assert!(validate_standalone_socket_path(path).is_err());
1012        assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
1013        assert!(validate_standalone_socket_path_with_policy(
1014            std::path::Path::new("/tmp/node-app/../escape.sock"),
1015            true,
1016        )
1017        .is_err());
1018    }
1019
1020    // ── ManifestCapabilities defaults ─────────────────────────────────────────
1021
1022    #[test]
1023    fn manifest_capabilities_defaults_to_empty() {
1024        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1025        assert!(m.capabilities.requires.is_empty());
1026        assert!(m.capabilities.provides.is_empty());
1027    }
1028
1029    // ── resolved_capability_provides (T28 standalone-registration shaping) ────
1030
1031    #[test]
1032    fn resolved_capability_provides_v1_only() {
1033        let json = r#"{
1034            "name": "example", "version": "1.0.0", "app_type": "bun",
1035            "provides": { "core.example.run": { "description": "Run example job" } }
1036        }"#;
1037        let m = parse_ok(json);
1038        let out = m.resolved_capability_provides();
1039        assert_eq!(out.len(), 1);
1040        assert_eq!(
1041            out.get("core.example.run").unwrap().description,
1042            "Run example job"
1043        );
1044    }
1045
1046    #[test]
1047    fn resolved_capability_provides_v2_names_get_blank_declaration() {
1048        let json = r#"{
1049            "manifest_version": 2, "name": "example", "version": "1.0.0",
1050            "app_type": "bun", "abi": "v1",
1051            "capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
1052        }"#;
1053        let m = parse_ok(json);
1054        let out = m.resolved_capability_provides();
1055        assert_eq!(out.len(), 2);
1056        assert_eq!(out.get("core.example.run").unwrap().description, "");
1057        assert!(out.get("core.example.run").unwrap().schema.is_none());
1058        // Only the part before the first ':' is used as the name.
1059        assert!(out.contains_key("core.example.other"));
1060        assert!(!out.contains_key("core.example.other:extra"));
1061    }
1062
1063    #[test]
1064    fn resolved_capability_provides_v1_wins_on_conflict() {
1065        let json = r#"{
1066            "manifest_version": 2, "name": "example", "version": "1.0.0",
1067            "app_type": "bun", "abi": "v1",
1068            "provides": { "core.example.run": { "description": "v1 wins" } },
1069            "capabilities": { "requires": [], "provides": ["core.example.run"] }
1070        }"#;
1071        let m = parse_ok(json);
1072        let out = m.resolved_capability_provides();
1073        assert_eq!(out.len(), 1);
1074        assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
1075    }
1076
1077    #[test]
1078    fn resolved_capability_provides_blank_v2_name_skipped() {
1079        let json = r#"{
1080            "manifest_version": 2, "name": "example", "version": "1.0.0",
1081            "app_type": "bun", "abi": "v1",
1082            "capabilities": { "requires": [], "provides": ["  ", "core.example.run"] }
1083        }"#;
1084        let m = parse_ok(json);
1085        let out = m.resolved_capability_provides();
1086        assert_eq!(out.len(), 1);
1087        assert!(out.contains_key("core.example.run"));
1088    }
1089
1090    #[test]
1091    fn resolved_capability_provides_empty_manifest_yields_empty_map() {
1092        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1093        assert!(m.resolved_capability_provides().is_empty());
1094    }
1095
1096    // ── ABI version ───────────────────────────────────────────────────────────
1097
1098    #[test]
1099    fn abi_v1_is_supported() {
1100        assert!(AbiVersion::V1.is_supported());
1101    }
1102
1103    // ── AppTier display ───────────────────────────────────────────────────────
1104
1105    #[test]
1106    fn app_tier_display() {
1107        assert_eq!(AppTier::FirstParty.to_string(), "first_party");
1108        assert_eq!(AppTier::Optional.to_string(), "optional");
1109        assert_eq!(AppTier::Development.to_string(), "development");
1110    }
1111
1112    #[test]
1113    fn app_tier_serde_roundtrip() {
1114        // Wire format must stay snake_case for the existing API contract.
1115        for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
1116            let json = serde_json::to_string(&tier).unwrap();
1117            let back: AppTier = serde_json::from_str(&json).unwrap();
1118            assert_eq!(
1119                tier, back,
1120                "roundtrip failed for {:?}: serialized as {}",
1121                tier, json
1122            );
1123        }
1124        assert_eq!(
1125            serde_json::to_string(&AppTier::Development).unwrap(),
1126            "\"development\""
1127        );
1128    }
1129
1130    // ── AppType display ───────────────────────────────────────────────────────
1131
1132    #[test]
1133    fn app_type_display() {
1134        assert_eq!(AppType::Native.to_string(), "native");
1135        assert_eq!(AppType::Bun.to_string(), "bun");
1136        assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
1137    }
1138
1139    #[test]
1140    fn platform_runtime_is_a_supported_packaging_type() {
1141        let manifest: AppManifest = serde_json::from_value(serde_json::json!({
1142            "manifest_version": 2,
1143            "abi": "v1",
1144            "name": "bun-runtime",
1145            "version": "1.0.0",
1146            "app_type": "platform-runtime",
1147            "entrypoint": "bun"
1148        }))
1149        .expect("platform runtime manifest should parse");
1150
1151        assert_eq!(manifest.app_type, AppType::PlatformRuntime);
1152        assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
1153    }
1154}