node-app-build 6.4.1

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! `node-app validate`
//!
//! Runs the full set of v2-aware validations on a project's `manifest.json`:
//!   - JSON Schema (manifest-v2.json) when available
//!   - Path safety on entrypoint / ui_path
//!   - Capability requirement parsing
//!   - ABI compatibility
//!   - Tier policy: native cdylib at the apt path is accepted only when the
//!     repo ships through the org-signed FirstParty pipeline (FR-028);
//!     otherwise it is rejected as an unsigned sideload.
//!
//! Returns the parsed `AppManifest` so callers (e.g. `package`) can reuse it.

use crate::manifest::{parse_manifest, validate_manifest, AppManifest, AppType};
use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};

pub fn run(project_path: &Path) -> Result<AppManifest> {
    let project_path = project_path.canonicalize().with_context(|| {
        format!(
            "could not canonicalize project path '{}'",
            project_path.display()
        )
    })?;
    let manifest_path = project_path.join("manifest.json");
    if !manifest_path.exists() {
        bail!(
            "no manifest.json at {} — did you run `node-app new` first?",
            manifest_path.display()
        );
    }

    let manifest = parse_manifest(&manifest_path)?;

    // For non-bundled (apt-distributed) packages we always treat the install
    // path as the apt path. The CLI is intended for community / first-party
    // additions; bundled-app authoring uses Cargo + manifest discovery
    // directly without going through `node-app`.
    let is_apt_target = true;
    // A native (cdylib) app is only permitted at the apt path when this repo
    // ships through the org-signed FirstParty pipeline (FR-028). We detect that
    // intent by the presence of a release workflow that GPG-signs the manifest
    // sidecar — see `detects_signing_pipeline`.
    let signed_pathway = detects_signing_pipeline(&project_path);
    validate_manifest(&manifest, is_apt_target, signed_pathway)?;

    // Verify the declared entrypoint actually exists on disk.
    let ep = manifest
        .entrypoint
        .clone()
        .unwrap_or_else(|| match manifest.app_type {
            AppType::Native => "app.so".into(),
            AppType::Bun => "dist/index.js".into(),
            AppType::Standalone => "app".into(),
            AppType::PlatformRuntime => "bun".into(),
        });
    let entry_full = project_path.join(&ep);
    if !entry_full.exists() {
        // For Bun the dist/ directory is built, so the missing entrypoint is
        // a soft warning — `package` will rebuild before staging.
        match manifest.app_type {
            AppType::Bun => {
                eprintln!(
                    "ℹ entrypoint '{}' not found yet (will be built during `package`)",
                    ep
                );
            }
            AppType::Native => {
                eprintln!(
                    "ℹ entrypoint '{}' not found yet (cdylib build artifact missing)",
                    ep
                );
            }
            AppType::Standalone => {
                eprintln!(
                    "ℹ entrypoint '{}' not found yet (binary build artifact missing; run `cargo build --release`)",
                    ep
                );
            }
            AppType::PlatformRuntime => {
                eprintln!(
                    "ℹ runtime entrypoint '{}' not found yet (package build artifact missing)",
                    ep
                );
            }
        }
    }

    // UI bundle directory check (informational).
    if manifest.has_ui {
        let ui_full: PathBuf = project_path.join(&manifest.ui_path);
        if !ui_full.is_dir() {
            eprintln!(
                "ℹ has_ui=true but {} is not yet a directory (build the UI before packaging)",
                ui_full.display()
            );
        }
    }

    println!("✓ schema v{} valid", manifest.manifest_version);
    println!("✓ all capabilities recognised");
    if let Some(abi) = &manifest.abi {
        println!("✓ ABI {} supported", abi);
    } else {
        println!("✓ ABI default (v1) supported");
    }
    // Reaching this point with a native app means `validate_manifest` accepted
    // it, which for the apt path requires the signed pathway to be present.
    match manifest.app_type {
        AppType::Native => {
            println!("✓ tier policy ok (native app — org-signed FirstParty pipeline detected)");
        }
        AppType::Bun => println!("✓ tier policy ok (bun app)"),
        AppType::Standalone => println!("✓ tier policy ok (standalone app)"),
        AppType::PlatformRuntime => println!("✓ tier policy ok (platform runtime package)"),
    }

    Ok(manifest)
}

/// Detects whether this repo ships through the org-signed FirstParty pipeline.
///
/// Per FR-028 (cycle 4, signature-based tier), a native (cdylib) app is granted
/// FirstParty trust at load time when its manifest sidecar carries a detached
/// GPG signature from a node-project org key. That signing happens in CI — the
/// per-repo release workflow copies `manifest.json` to a sidecar and runs
/// `gpg --detach-sign` on it. At `validate` time (in the source repo, before
/// packaging) the signature does not exist yet, so we detect *intent*: the
/// presence of a release workflow that signs the manifest sidecar.
///
/// Detection requires BOTH markers in a single workflow file, to avoid
/// false-positives from unrelated GPG usage:
///   1. `--detach-sign` — a detached GPG signature is produced, and
///   2. a manifest-sidecar reference — the `SIDECAR` variable or the literal
///      "manifest sidecar" (the signing step's name), proving it is the
///      *manifest* being signed, which is what grants FirstParty.
///
/// Both markers are deliberately specific to the manifest-sidecar signing
/// idiom (not a broad token like `Manifests`, which could collide with
/// unrelated artifact paths), so an unrelated `--detach-sign` on some other
/// file in the same workflow does not read as the FirstParty pipeline.
///
/// Returns `false` (→ native at the apt path is rejected as an unsigned
/// sideload) when no such workflow is found, or when `.github/workflows/` is
/// absent/unreadable.
fn detects_signing_pipeline(project_path: &Path) -> bool {
    let workflows_dir = project_path.join(".github").join("workflows");
    let Ok(entries) = std::fs::read_dir(&workflows_dir) else {
        return false;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        let is_yaml = path
            .extension()
            .and_then(|e| e.to_str())
            .map(|e| e.eq_ignore_ascii_case("yml") || e.eq_ignore_ascii_case("yaml"))
            .unwrap_or(false);
        if !is_yaml {
            continue;
        }
        let Ok(body) = std::fs::read_to_string(&path) else {
            continue;
        };
        let signs = body.contains("--detach-sign");
        let sidecar = body.contains("SIDECAR") || body.contains("manifest sidecar");
        if signs && sidecar {
            return true;
        }
    }
    false
}

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

    fn write_workflow(dir: &Path, name: &str, body: &str) {
        let wf = dir.join(".github").join("workflows");
        fs::create_dir_all(&wf).unwrap();
        fs::write(wf.join(name), body).unwrap();
    }

    #[test]
    fn detects_signed_release_workflow() {
        let proj = tempfile::tempdir().unwrap();
        write_workflow(
            proj.path(),
            "release.yml",
            "steps:\n  - name: Publish manifest sidecar\n    run: |\n      cp manifest.json \"$SIDECAR\"\n      gpg --detach-sign --armor \"$SIDECAR\"\n",
        );
        assert!(detects_signing_pipeline(proj.path()));
    }

    #[test]
    fn no_workflows_dir_is_not_signed() {
        let proj = tempfile::tempdir().unwrap();
        assert!(!detects_signing_pipeline(proj.path()));
    }

    #[test]
    fn workflow_without_signing_is_not_signed() {
        let proj = tempfile::tempdir().unwrap();
        // A CI workflow that builds but never signs the manifest sidecar.
        write_workflow(
            proj.path(),
            "ci.yml",
            "steps:\n  - run: cargo build --release\n",
        );
        assert!(!detects_signing_pipeline(proj.path()));
    }

    #[test]
    fn unrelated_gpg_without_sidecar_is_not_signed() {
        let proj = tempfile::tempdir().unwrap();
        // `--detach-sign` present but not signing the manifest sidecar — must
        // NOT be mistaken for the FirstParty pipeline.
        write_workflow(
            proj.path(),
            "release.yml",
            "steps:\n  - run: gpg --detach-sign --armor artifact.bin\n",
        );
        assert!(!detects_signing_pipeline(proj.path()));
    }

    #[test]
    fn detects_signing_in_yaml_extension() {
        let proj = tempfile::tempdir().unwrap();
        write_workflow(
            proj.path(),
            "publish.yaml",
            "run: |\n  SIDECAR=\"out/app.json\"\n  gpg --detach-sign --armor \"$SIDECAR\"\n",
        );
        assert!(detects_signing_pipeline(proj.path()));
    }

    #[test]
    fn broad_manifests_token_alone_is_not_signed() {
        // A workflow that signs some artifact and merely mentions a `Manifests`
        // directory for an unrelated purpose must NOT be read as the FirstParty
        // pipeline — detection keys on the specific sidecar idiom, not the broad
        // token (PR #1124 review hardening).
        let proj = tempfile::tempdir().unwrap();
        write_workflow(
            proj.path(),
            "release.yml",
            "steps:\n  - run: gpg --detach-sign --armor artifact.bin\n  - run: cp x Manifests/\n",
        );
        assert!(!detects_signing_pipeline(proj.path()));
    }
}