noxid-cli 0.2.1

The Noxid compiler command line: check, build, test, adapt, and the agent surface
//! Stage 0.1 security patch (execution-plan-2026-09): the runtime principal
//! authority binds only to the compiler-owned drizzle adapter, and the Node and
//! Deno static handlers never serve server source or build metadata.

use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::process::{Child, Command, Output, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};

static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);

struct Fixture {
    root: PathBuf,
}

impl Fixture {
    fn new(label: &str) -> Self {
        let ordinal = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed);
        let root = std::env::temp_dir().join(format!(
            "noxid-stage0-{label}-{}-{ordinal}",
            std::process::id()
        ));
        fs::create_dir_all(&root).expect("create fixture root");
        Self { root }
    }

    fn write(&self, relative: &str, contents: &str) {
        let path = self.root.join(relative);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).expect("create fixture parent");
        }
        fs::write(path, contents).expect("write fixture file");
    }

    fn noxid(&self, args: &[&str]) -> Output {
        Command::new(env!("CARGO_BIN_EXE_noxid"))
            .args(args)
            .current_dir(&self.root)
            .env_remove("DATABASE_URL")
            .env_remove("VERCEL")
            .env_remove("NETLIFY")
            .env_remove("RAILWAY_ENVIRONMENT")
            .output()
            .expect("run noxid")
    }
}

impl Drop for Fixture {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.root);
    }
}

fn output_text(output: &Output) -> String {
    format!(
        "status: {}\nstdout:\n{}\nstderr:\n{}",
        output.status,
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    )
}

fn write_endpoint_project(fixture: &Fixture, host_source: &str) {
    fixture.write("Noxid.toml", "[app]\ntitle = \"Stage 0\"\n");
    fixture.write("package.json", "{\"type\":\"module\"}\n");
    fixture.write(
        "server/api/probe.get.nox",
        "endpoint Probe { result: String }\n",
    );
    fixture.write("server/host.ts", host_source);
}

#[test]
fn build_refuses_a_developer_module_that_exports_the_principal_authority_installer() {
    let fixture = Fixture::new("installer-shadowed");
    write_endpoint_project(
        &fixture,
        "import { install } from './utils/authority.js';\nvoid install;\nexport const endpoints = Object.freeze({ 'endpoint:Probe@1': async () => 'ok' });\n",
    );
    fixture.write(
        "server/utils/authority.ts",
        "export function __installNoxidPrincipalAuthority() { return { bind() {} }; }\nexport const install = __installNoxidPrincipalAuthority;\n",
    );
    let output = fixture.noxid(&["build", ".", "--out-dir", "dist"]);
    let text = output_text(&output);
    assert!(!output.status.success(), "{text}");
    assert!(
        text.contains("error[PRINCIPAL_AUTHORITY_INSTALLER_SHADOWED]"),
        "{text}"
    );
    assert!(text.contains("server__utils__authority.js"), "{text}");
    assert!(!fixture.root.join("dist/server/handler.js").exists());
}

#[test]
fn build_without_any_installer_wires_no_principal_authority_import() {
    let fixture = Fixture::new("installer-absent");
    write_endpoint_project(
        &fixture,
        "export const endpoints = Object.freeze({ 'endpoint:Probe@1': async () => 'ok' });\n",
    );
    let output = fixture.noxid(&["build", ".", "--out-dir", "dist"]);
    assert!(output.status.success(), "{}", output_text(&output));
    let handler =
        fs::read_to_string(fixture.root.join("dist/server/handler.js")).expect("read handler");
    assert!(
        !handler.contains("import { __installNoxidPrincipalAuthority }"),
        "{handler}"
    );
    assert!(
        handler.contains("typeof __installNoxidPrincipalAuthority === \"function\""),
        "runtime guard for an absent authority must remain: {handler}"
    );
}

fn free_port() -> u16 {
    TcpListener::bind("127.0.0.1:0")
        .expect("bind ephemeral port")
        .local_addr()
        .expect("read ephemeral port")
        .port()
}

struct NodeServer {
    child: Child,
    port: u16,
}

impl NodeServer {
    // The child is reaped by `Drop` (kill + wait) on every path, including the
    // timeout below, which reaps before panicking.
    #[allow(clippy::zombie_processes)]
    fn start(directory: &std::path::Path) -> Self {
        let port = free_port();
        let child = Command::new("node")
            .arg("server.mjs")
            .current_dir(directory)
            .env("PORT", port.to_string())
            .env_remove("DATABASE_URL")
            .stdout(Stdio::null())
            .stderr(Stdio::piped())
            .spawn()
            .expect("spawn emitted Node server");
        let deadline = Instant::now() + Duration::from_secs(30);
        while Instant::now() < deadline {
            if TcpStream::connect(("127.0.0.1", port)).is_ok() {
                return Self { child, port };
            }
            std::thread::sleep(Duration::from_millis(100));
        }
        let mut child = child;
        let _ = child.kill();
        let _ = child.wait();
        panic!("emitted Node server did not listen on {port} within 30 seconds");
    }

    fn status(&self, path: &str) -> u16 {
        let mut stream =
            TcpStream::connect(("127.0.0.1", self.port)).expect("connect to emitted server");
        stream
            .set_read_timeout(Some(Duration::from_secs(10)))
            .expect("set read timeout");
        write!(
            stream,
            "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"
        )
        .expect("write request");
        let mut response = String::new();
        let _ = stream.read_to_string(&mut response);
        response
            .split_whitespace()
            .nth(1)
            .and_then(|code| code.parse().ok())
            .unwrap_or_else(|| panic!("no status line for {path}: {response}"))
    }
}

impl Drop for NodeServer {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

#[test]
fn node_adapter_serves_assets_but_never_server_source_or_build_metadata() {
    let fixture = Fixture::new("node-static-root");
    fixture.write("Noxid.toml", "[app]\ntitle = \"Static root\"\n");
    fixture.write(
        "src/routes/+page.nox",
        "component Home { route { title: \"Home\" } view { <main>Home</main> } }\n",
    );
    let adapted = fixture.noxid(&["adapt", ".", "--adapter", "node", "--out-dir", "dist"]);
    assert!(adapted.status.success(), "{}", output_text(&adapted));
    let dist = fixture.root.join("dist");
    assert!(dist.join("server.mjs").is_file());
    assert!(dist.join("app.js").is_file(), "build did not emit app.js");
    assert!(
        dist.join("app.manifest.json").is_file(),
        "build did not emit app.manifest.json"
    );
    // Plant the artifacts a real full-stack build would leave behind so the
    // refusal is exercised against files that exist on disk.
    fs::create_dir_all(dist.join("server")).expect("create server directory");
    fs::write(dist.join("server/handler.js"), "export const secret = 1;\n")
        .expect("write planted server module");
    fs::write(dist.join("api-contract.json"), "{}\n").expect("write planted contract");

    let server = NodeServer::start(&dist);
    assert_eq!(server.status("/app.js"), 200, "app.js must stay public");
    assert_eq!(server.status("/"), 200, "index must stay public");
    assert_eq!(server.status("/server/handler.js"), 404);
    assert_eq!(server.status("/server/"), 404);
    assert_eq!(server.status("/server"), 404);
    assert_eq!(server.status("/app.manifest.json"), 404);
    assert_eq!(server.status("/app.devtools.json"), 404);
    assert_eq!(server.status("/api-contract.json"), 404);
    assert!(
        matches!(server.status("/..%2Fserver%2Fhandler.js"), 400 | 404),
        "traversal is refused before any file is read"
    );
    drop(server);

    let deno = fixture.noxid(&["adapt", ".", "--adapter", "deno", "--out-dir", "dist-deno"]);
    assert!(deno.status.success(), "{}", output_text(&deno));
    let deno_server = fs::read_to_string(fixture.root.join("dist-deno/server.ts"))
        .expect("Deno adapter emitted server.ts");
    assert!(deno_server.contains("Deno.serve"), "{deno_server}");
    fs::copy(
        fixture.root.join("dist-deno/server.ts"),
        fixture.root.join("dist-deno/server.mjs"),
    )
    .expect("stage Deno entry for the Node-hosted stub");
    fixture.write("dist-deno/server/handler.js", "export const secret = 1;\n");
    fixture.write(
        "deno-static.mjs",
        r#"import { readFile, realpath, stat } from "node:fs/promises";
const reads = [];
globalThis.Deno = {
  serve: (_options, handler) => { globalThis.__handler = handler; return { shutdown: async () => {} }; },
  env: { get: () => undefined, toObject: () => ({}) },
  readFile: async (path) => { reads.push(String(path)); return new Uint8Array(await readFile(path)); },
  realPath: (path) => realpath(path),
  stat: async (path) => { const value = await stat(path); return { isDirectory: value.isDirectory(), isFile: value.isFile(), size: value.size }; },
  addSignalListener: () => {},
};
await import("./dist-deno/server.mjs");
const response = await globalThis.__handler(new Request("http://noxid.test/server/handler.js"));
console.log(JSON.stringify({ status: response.status, reads }));
"#,
    );
    let driven = Command::new("node")
        .arg("deno-static.mjs")
        .current_dir(&fixture.root)
        .output()
        .expect("drive emitted Deno static handler");
    let report = String::from_utf8_lossy(&driven.stdout);
    assert!(driven.status.success(), "{}", output_text(&driven));
    assert!(
        report.contains("\"status\":404") && report.contains("\"reads\":[]"),
        "the Deno static handler reached or served planted server source: {report}"
    );
    assert!(
        deno_server.contains("const isNoxidReservedDeploymentPath = (value)")
            && deno_server
                .contains("relative.includes(\"%\") || isNoxidReservedDeploymentPath(relative)")
            && deno_server.contains("\"server\""),
        "{deno_server}"
    );
}

// ---------------------------------------------------------------------------
// Checkpoint-3 security read, N10: the post-realpath reserved re-check is what
// closes the case- and normalization-folding class of bypass, and nothing said
// so. The upfront matcher lowercases with JS `.toLowerCase()`, which does not
// fold U+017F LATIN SMALL LETTER LONG S — but APFS and NTFS do, so
// `/ſerver/handler.js` opens the real `server/` directory. Only the second
// check, run on the path `fs.realpath` actually resolved, sees that.

/// Copy a tree. Small and shallow by construction: this is one emitted
/// deployment root.
fn copy_tree(from: &std::path::Path, to: &std::path::Path) {
    fs::create_dir_all(to).expect("create copy destination");
    for entry in fs::read_dir(from).expect("read deployment root") {
        let entry = entry.expect("deployment entry");
        let target = to.join(entry.file_name());
        if entry.file_type().expect("entry type").is_dir() {
            copy_tree(&entry.path(), &target);
        } else {
            fs::copy(entry.path(), &target).expect("copy deployment file");
        }
    }
}

#[test]
fn the_post_realpath_reserved_recheck_is_what_closes_case_folded_spellings() {
    let fixture = Fixture::new("realpath-recheck");
    fixture.write("Noxid.toml", "[app]\ntitle = \"Realpath re-check\"\n");
    fixture.write(
        "src/routes/+page.nox",
        "component Home { route { title: \"Home\" } view { <main>Home</main> } }\n",
    );
    let adapted = fixture.noxid(&["adapt", ".", "--adapter", "node", "--out-dir", "dist"]);
    assert!(adapted.status.success(), "{}", output_text(&adapted));
    let dist = fixture.root.join("dist");
    fs::create_dir_all(dist.join("server")).expect("create server directory");
    fs::write(
        dist.join("server/handler.js"),
        "export const secret = \"REALPATH-RECHECK-LEAK\";\n",
    )
    .expect("plant server source");
    fs::write(dist.join("api-contract.json"), "{\"planted\":true}\n").expect("plant contract");

    // The source half of the pin: the native `realpath` and the second check
    // that consumes it. A refactor to `fs.realpathSync` (the JS one, which does
    // not canonicalize case) or a dropped re-check opens the class below.
    let emitted = fs::read_to_string(dist.join("server.mjs")).expect("read emitted server");
    assert!(
        emitted.contains("import fs from \"node:fs/promises\";"),
        "the emitted handler no longer imports the native realpath:\n{emitted}"
    );
    let realpath = emitted
        .find("file = await fs.realpath(file);")
        .expect("emitted handler resolves the served file through realpath");
    let recheck = emitted
        .find("if (isReservedStaticPath(served) || isNoxidReservedDeploymentPath(served))")
        .expect("emitted handler re-checks the canonical served path");
    assert!(
        realpath < recheck,
        "the reserved re-check no longer runs on the realpath-canonical path"
    );

    // The behavioural half. `%C5%BF` is U+017F; `"ſ".toLowerCase() === "ſ"`, so
    // the upfront matcher cannot refuse these — the filesystem folds them and
    // the re-check catches the result.
    let server = NodeServer::start(&dist);
    assert_eq!(server.status("/"), 200, "the shell must stay public");
    for path in [
        "/%C5%BFerver/handler.js",
        "/api-contract.j%C5%BFon",
        "/SERVER/handler.js",
        "/API-CONTRACT.JSON",
    ] {
        assert_eq!(
            server.status(path),
            404,
            "`{path}` reached a compiler-owned artifact"
        );
    }
    drop(server);

    // The mutation. This is the only way to show the *re-check* is what closed
    // those, rather than the upfront matcher having caught them anyway: delete
    // it from a copy of the emitted server and watch the long-s spelling leak.
    // It only means anything on a folding filesystem, so the fold is measured
    // rather than assumed.
    let folds = fs::canonicalize(dist.join("\u{17f}erver/handler.js"))
        .is_ok_and(|resolved| resolved.ends_with("server/handler.js"));
    if !folds {
        eprintln!(
            "the post-realpath re-check mutation: SKIP (this filesystem does not fold U+017F)"
        );
        return;
    }

    let mutated = fixture.root.join("dist-mutated");
    copy_tree(&dist, &mutated);
    let entry = mutated.join("server.mjs");
    let source = fs::read_to_string(&entry).expect("read copied server");
    let disabled = source.replace(
        "if (isReservedStaticPath(served) || isNoxidReservedDeploymentPath(served)) {",
        "if (false) {",
    );
    assert_ne!(source, disabled, "the re-check was not found to disable");
    fs::write(&entry, &disabled).expect("write the mutated server");

    let leaking = NodeServer::start(&mutated);
    assert_eq!(
        leaking.status("/%C5%BFerver/handler.js"),
        200,
        "removing the post-realpath re-check did not open the long-s bypass, so \
         this test is not measuring the check it claims to pin"
    );
    assert_eq!(
        leaking.status("/api-contract.j%C5%BFon"),
        200,
        "removing the post-realpath re-check did not open the long-s bypass for \
         build metadata"
    );
    // The upfront matcher is still there in the mutated copy and still refuses
    // the plain spelling, which is exactly why it is not sufficient.
    assert_eq!(leaking.status("/server/handler.js"), 404);
}