node-app-build 5.22.0

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! Lightweight manifest.json parser used by `validate` and `package`.
//!
//! The full manifest type model lives in `core/domain/src/models/app_manifest.rs`
//! (T012). To avoid coupling the CLI to the full domain crate during early
//! development (and to keep `node-app` cheap to build for first-time
//! contributors), we re-implement the small subset of fields the CLI needs.
//! When the domain crate stabilises, this module will switch to depending on
//! `node-domain` and re-exporting its types.

use anyhow::{anyhow, bail, Context, Result};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::path::Path;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppManifest {
    #[serde(default = "default_manifest_version")]
    pub manifest_version: u8,
    pub name: String,
    pub version: String,
    pub app_type: AppType,
    #[serde(default)]
    pub abi: Option<String>,
    #[serde(default)]
    pub entrypoint: Option<String>,
    #[serde(default)]
    pub hot_reload: Option<String>,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub has_ui: bool,
    #[serde(default)]
    pub ui_path: Option<String>,
    #[serde(default)]
    pub capabilities: Option<ManifestCapabilities>,
}

fn default_manifest_version() -> u8 {
    1
}

#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AppType {
    Native,
    Bun,
    Standalone,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ManifestCapabilities {
    #[serde(default)]
    pub requires: Vec<String>,
    #[serde(default)]
    pub provides: Vec<String>,
}

pub fn parse_manifest(path: &Path) -> Result<AppManifest> {
    let raw = std::fs::read_to_string(path)
        .with_context(|| format!("read {}", path.display()))?;
    let manifest: AppManifest = serde_json::from_str(&raw)
        .with_context(|| format!("parse {} as manifest.json", path.display()))?;
    Ok(manifest)
}

/// Run all v2-aware validations on a manifest. Returns Ok(()) on full pass,
/// or an error describing the first failure.
///
/// `is_apt_install_target` indicates whether the package is intended to be
/// distributed at the apt path (`/usr/lib/node/apps/<name>/`). When true,
/// native (cdylib) manifests are rejected (T118 — path-based tier rule, R8).
pub fn validate_manifest(m: &AppManifest, is_apt_install_target: bool) -> Result<()> {
    // Name pattern (lowercase alphanumeric + hyphens, with optional publisher prefix).
    static NAME_RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
        Regex::new(r"^[a-z][a-z0-9-]*(/([a-z][a-z0-9-]*))?$").unwrap()
    });
    if !NAME_RE.is_match(&m.name) {
        bail!(
            "manifest name '{}' is invalid (expected lowercase + hyphens, optional publisher/ prefix)",
            m.name
        );
    }
    if m.name.starts_with("node-app-") {
        bail!("manifest name must not start with 'node-app-' (the .deb script adds the prefix)");
    }

    // SemVer-ish.
    static SEMVER_RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
        Regex::new(r"^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.+-]+)?$").unwrap()
    });
    if !SEMVER_RE.is_match(&m.version) {
        bail!("manifest version '{}' is not a valid SemVer", m.version);
    }

    // v2 manifests must declare an ABI in {v1}.
    if m.manifest_version >= 2 {
        let abi = m.abi.as_deref().ok_or_else(|| {
            anyhow!("manifest_version=2 requires an `abi` field")
        })?;
        if abi != "v1" {
            bail!("unsupported abi '{}'; only 'v1' is recognised by the runtime", abi);
        }
    }

    // hot_reload values.
    if let Some(hr) = m.hot_reload.as_deref() {
        match hr {
            "supported" | "experimental" | "unsupported" => {}
            other => bail!(
                "hot_reload '{}' is invalid (expected supported|experimental|unsupported)",
                other
            ),
        }
    }

    // Path safety on entrypoint and ui_path. Same rule both fields:
    // - Match path-component regex.
    // - No '..' segments.
    // - No leading '/'.
    static PATH_RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
        Regex::new(r"^[a-zA-Z0-9_][a-zA-Z0-9_./-]*$").unwrap()
    });
    let check_path = |label: &str, p: &str| -> Result<()> {
        if !PATH_RE.is_match(p) {
            bail!("{} path '{}' has illegal characters", label, p);
        }
        if p.split('/').any(|seg| seg == "..") {
            bail!("{} path '{}' contains '..' segment", label, p);
        }
        if p.starts_with('/') {
            bail!("{} path '{}' must be relative", label, p);
        }
        Ok(())
    };
    if let Some(ep) = m.entrypoint.as_deref() {
        check_path("entrypoint", ep)?;
    }
    if m.has_ui {
        let ui = m.ui_path.as_deref().ok_or_else(|| {
            anyhow!("has_ui=true but ui_path is missing")
        })?;
        check_path("ui_path", ui)?;
    }

    // Capability strings parse correctly.
    if let Some(caps) = &m.capabilities {
        for c in &caps.requires {
            parse_capability_requirement(c)
                .with_context(|| format!("invalid capability requirement: '{}'", c))?;
        }
        for c in &caps.provides {
            parse_capability_provides(c)
                .with_context(|| format!("invalid capability provides: '{}'", c))?;
        }
    }

    // Tier check: native (cdylib) cannot install at the apt path.
    if is_apt_install_target && m.app_type == AppType::Native {
        bail!(
            "native (cdylib) apps cannot ship at the apt-install path \
             (/usr/lib/node/apps/). Native apps must be bundled with `econ-v1`. \
             Switch app_type to 'bun' or coordinate with the project to add a \
             bundled-app slot."
        );
    }

    Ok(())
}

/// Tiny capability-requirement parser used during validate. Mirrors the
/// grammar in `core/domain/src/models/capability.rs::parse_capability` —
/// we keep this in sync manually for now (single source of truth lives in
/// the domain crate; CI cross-checks the two parsers in T128).
fn parse_capability_requirement(s: &str) -> Result<()> {
    let mut parts = s.split(':');
    let ns = parts
        .next()
        .ok_or_else(|| anyhow!("empty capability"))?
        .trim();
    if ns.is_empty() {
        bail!("capability namespace is empty");
    }
    static NS_RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
        Regex::new(r"^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$").unwrap()
    });
    if !NS_RE.is_match(ns) {
        bail!(
            "capability namespace '{}' must be dotted lowercase (e.g. core.lightning.payment.send)",
            ns
        );
    }
    let mut seen_daily = false;
    for part in parts {
        if let Some(rest) = part.strip_prefix("max=") {
            // <N>(sat|msat)/(day|tx)
            let (num_unit, period) = rest
                .split_once('/')
                .ok_or_else(|| anyhow!("max constraint missing /period: '{}'", part))?;
            let (num, unit) = num_unit
                .strip_suffix("msat")
                .map(|n| (n, "msat"))
                .or_else(|| num_unit.strip_suffix("sat").map(|n| (n, "sat")))
                .ok_or_else(|| anyhow!("max value must end in 'sat' or 'msat': '{}'", num_unit))?;
            let _: u64 = num
                .parse()
                .with_context(|| format!("max value '{}' must be an integer", num))?;
            match (unit, period) {
                ("sat", "day") | ("msat", "day") => {
                    if seen_daily {
                        bail!("duplicate max=Nsat/day constraint on '{}'", ns);
                    }
                    seen_daily = true;
                }
                ("sat", "tx") | ("msat", "tx") => {}
                _ => bail!(
                    "unsupported constraint period '{}' (expected day or tx)",
                    period
                ),
            }
        } else {
            // Plain `:scope` token — accepted, no semantic validation here.
            if part.is_empty() {
                bail!("empty constraint segment in '{}'", s);
            }
        }
    }
    Ok(())
}

fn parse_capability_provides(s: &str) -> Result<()> {
    static NS_RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
        Regex::new(r"^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$").unwrap()
    });
    if !NS_RE.is_match(s) {
        bail!("capability provides '{}' must be dotted lowercase", s);
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn min_v2(name: &str, app_type: AppType) -> AppManifest {
        AppManifest {
            manifest_version: 2,
            name: name.into(),
            version: "1.0.0".into(),
            app_type,
            abi: Some("v1".into()),
            entrypoint: Some(match app_type {
                AppType::Native => "app.so".into(),
                AppType::Bun => "dist/index.js".into(),
                AppType::Standalone => "app".into(),
            }),
            hot_reload: None,
            description: None,
            has_ui: false,
            ui_path: None,
            capabilities: None,
        }
    }

    #[test]
    fn accepts_v1_manifest_without_abi() {
        let mut m = min_v2("foo", AppType::Bun);
        m.manifest_version = 1;
        m.abi = None;
        validate_manifest(&m, true).unwrap();
    }

    #[test]
    fn rejects_v2_without_abi() {
        let mut m = min_v2("foo", AppType::Bun);
        m.abi = None;
        let err = validate_manifest(&m, true).unwrap_err();
        assert!(err.to_string().contains("requires an `abi`"));
    }

    #[test]
    fn rejects_invalid_name() {
        let mut m = min_v2("FOO", AppType::Bun);
        let err = validate_manifest(&m, true).unwrap_err();
        assert!(err.to_string().contains("invalid"));
        m.name = "node-app-bar".into();
        let err = validate_manifest(&m, true).unwrap_err();
        assert!(err.to_string().contains("must not start with 'node-app-'"));
    }

    #[test]
    fn rejects_traversal_in_entrypoint() {
        let mut m = min_v2("foo", AppType::Bun);
        m.entrypoint = Some("../etc/passwd".into());
        let err = validate_manifest(&m, true).unwrap_err();
        assert!(err.to_string().contains(".."));
    }

    #[test]
    fn rejects_native_at_apt_path() {
        let m = min_v2("foo", AppType::Native);
        let err = validate_manifest(&m, true).unwrap_err();
        assert!(err.to_string().contains("native"));
    }

    #[test]
    fn accepts_native_when_bundled() {
        let m = min_v2("foo", AppType::Native);
        validate_manifest(&m, false).unwrap();
    }

    #[test]
    fn accepts_standalone_at_apt_path() {
        // Standalone apps are distributed via apt as binaries — allowed at apt path.
        let m = min_v2("my-service", AppType::Standalone);
        validate_manifest(&m, true).unwrap();
    }

    #[test]
    fn parses_constraint_strings() {
        parse_capability_requirement("core.storage.kv").unwrap();
        parse_capability_requirement("core.lightning.payment.send:max=500sat/day").unwrap();
        parse_capability_requirement("core.lightning.payment.send:max=1000msat/tx").unwrap();
        parse_capability_requirement(
            "core.lightning.payment.send:max=500sat/day:max=10000msat/tx",
        )
        .unwrap();
        assert!(parse_capability_requirement("CORE.bad").is_err());
        assert!(parse_capability_requirement("core.bad:max=foo/day").is_err());
        assert!(parse_capability_requirement("core.bad:max=10sat/year").is_err());
        // duplicate daily caps rejected
        assert!(parse_capability_requirement(
            "core.bad:max=10sat/day:max=20sat/day"
        )
        .is_err());
    }
}