eggrd 0.1.3

A drop-in Rust edge proxy that gives any app a secure front door: auth, rate limiting, and hardened response headers, with zero changes to the upstream app.
Documentation
//! Static & edge host config generator (Phase 5 / v2.5).
//!
//! Static/edge hosts (Netlify, Cloudflare Pages, Vercel) can't run EdgeGuard's long-lived proxy
//! process, but they *can* apply EdgeGuard's **response-hardening** headers at their own edge.
//! This module renders the `[headers]` policy into the native config each platform understands —
//! a `_headers` file or an edge-middleware snippet — so a project hosted there gets the same
//! security headers the proxy would inject.
//!
//! The header set comes from [`crate::proxy::security_headers`], the *same* source of truth the
//! live proxy uses, so generated output can't drift from runtime behavior.
//!
//! **Out of scope for a static file:** EdgeGuard's cookie hardening and leaky-header stripping
//! act on the upstream's real response and can't be expressed as "always add this header"; and
//! a `_headers` file can't authenticate or rate-limit. For the full request+response pipeline
//! (auth, cookie/`Set-Cookie` rewriting, header stripping) at the edge, use the Rust→WASM
//! Cloudflare Worker in `worker/`.

use anyhow::Result;

use crate::config::Config;

/// A static-host / edge target the generator can emit config for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
    /// A `_headers` file — the format shared by **Netlify** and **Cloudflare Pages**.
    Headers,
    /// A `vercel.json` `headers` array (Vercel's static header config).
    Vercel,
    /// Vercel **Edge Middleware** (`middleware.ts`).
    VercelMiddleware,
    /// A **Netlify Edge Function** (Deno/TypeScript).
    NetlifyEdge,
}

impl Target {
    /// Parse a `--target` value (case-insensitive), accepting a few friendly aliases.
    pub fn parse(s: &str) -> Result<Target> {
        Ok(match s.trim().to_ascii_lowercase().as_str() {
            "_headers" | "headers" | "netlify" | "cloudflare-pages" | "cf-pages" => Target::Headers,
            "vercel" | "vercel.json" => Target::Vercel,
            "vercel-middleware" | "middleware" => Target::VercelMiddleware,
            "netlify-edge" | "edge" => Target::NetlifyEdge,
            other => anyhow::bail!(
                "unknown generate target {other:?} (expected one of: \
                 _headers, vercel, vercel-middleware, netlify-edge)"
            ),
        })
    }

    /// The conventional output filename for this target (used when `--out` is a directory or for
    /// the "wrote ..." hint).
    pub fn filename(self) -> &'static str {
        match self {
            Target::Headers => "_headers",
            Target::Vercel => "vercel.json",
            Target::VercelMiddleware => "middleware.ts",
            Target::NetlifyEdge => "edgeguard.ts",
        }
    }
}

/// Render the configured `[headers]` policy as the native config for `target`.
pub fn generate(cfg: &Config, target: Target) -> String {
    let headers = crate::proxy::security_headers(&cfg.headers);
    match target {
        Target::Headers => render_headers_file(&headers),
        Target::Vercel => render_vercel_json(&headers),
        Target::VercelMiddleware => render_vercel_middleware(&headers),
        Target::NetlifyEdge => render_netlify_edge(&headers),
    }
}

/// A `_headers` file (Netlify + Cloudflare Pages share this format). Path pattern `/*` then one
/// `Name: Value` line per header, indented two spaces.
fn render_headers_file(headers: &[(&'static str, String)]) -> String {
    let mut out = String::new();
    out.push_str("# Generated by `edgeguard generate --target _headers`.\n");
    out.push_str("# Response-hardening headers for Netlify and Cloudflare Pages (shared `_headers` format).\n");
    out.push_str(
        "# NOTE: a static `_headers` file can only ADD headers. EdgeGuard's cookie hardening,\n",
    );
    out.push_str(
        "# leaky-header stripping (Server / X-Powered-By), auth, and rate limiting need a live\n",
    );
    out.push_str(
        "# proxy or the Cloudflare Worker (see EdgeGuard's worker/). Review before committing.\n",
    );
    out.push_str("/*\n");
    for (name, value) in headers {
        out.push_str("  ");
        out.push_str(name);
        out.push_str(": ");
        out.push_str(value);
        out.push('\n');
    }
    out
}

/// A `vercel.json` with a single `headers` rule matching every path. Strict JSON (no comments),
/// built via `serde_json` so values are correctly escaped.
fn render_vercel_json(headers: &[(&'static str, String)]) -> String {
    let entries: Vec<serde_json::Value> = headers
        .iter()
        .map(|(k, v)| serde_json::json!({ "key": k, "value": v }))
        .collect();
    let doc = serde_json::json!({
        "headers": [
            { "source": "/(.*)", "headers": entries }
        ]
    });
    // Serializing a plain `Value` is infallible; fall back to an empty object out of caution.
    let mut s = serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".to_string());
    s.push('\n');
    s
}

/// Vercel Edge Middleware (`middleware.ts`): set the hardening headers on every response.
fn render_vercel_middleware(headers: &[(&'static str, String)]) -> String {
    let mut out = String::new();
    out.push_str("// Generated by `edgeguard generate --target vercel-middleware`.\n");
    out.push_str("// Vercel Edge Middleware: applies EdgeGuard's response-hardening headers to every response.\n");
    out.push_str("// Cookie hardening, leaky-header stripping, auth, and rate limiting need the full proxy\n");
    out.push_str("// or EdgeGuard's Cloudflare Worker (worker/). Review before committing.\n");
    out.push_str("import { NextResponse } from \"next/server\";\n\n");
    out.push_str("export const config = { matcher: \"/:path*\" };\n\n");
    out.push_str("const SECURITY_HEADERS: Record<string, string> = {\n");
    out.push_str(&js_object_entries(headers, "  "));
    out.push_str("};\n\n");
    out.push_str("export function middleware() {\n");
    out.push_str("  const res = NextResponse.next();\n");
    out.push_str("  for (const [name, value] of Object.entries(SECURITY_HEADERS)) {\n");
    out.push_str("    res.headers.set(name, value);\n");
    out.push_str("  }\n");
    out.push_str("  return res;\n");
    out.push_str("}\n");
    out
}

/// A Netlify Edge Function (Deno/TypeScript) that hardens the downstream response.
fn render_netlify_edge(headers: &[(&'static str, String)]) -> String {
    let mut out = String::new();
    out.push_str("// Generated by `edgeguard generate --target netlify-edge`.\n");
    out.push_str("// Netlify Edge Function: adds EdgeGuard's response-hardening headers to every response.\n");
    out.push_str("// Wire it up in netlify.toml:\n");
    out.push_str("//   [[edge_functions]]\n");
    out.push_str("//   path = \"/*\"\n");
    out.push_str("//   function = \"edgeguard\"\n");
    out.push_str("// Cookie hardening, leaky-header stripping, auth, and rate limiting need the full proxy\n");
    out.push_str("// or EdgeGuard's Cloudflare Worker (worker/). Review before committing.\n");
    out.push_str("import type { Context } from \"https://edge.netlify.com\";\n\n");
    out.push_str("const SECURITY_HEADERS: Record<string, string> = {\n");
    out.push_str(&js_object_entries(headers, "  "));
    out.push_str("};\n\n");
    out.push_str(
        "export default async (_request: Request, context: Context): Promise<Response> => {\n",
    );
    out.push_str("  const response = await context.next();\n");
    out.push_str("  for (const [name, value] of Object.entries(SECURITY_HEADERS)) {\n");
    out.push_str("    response.headers.set(name, value);\n");
    out.push_str("  }\n");
    out.push_str("  return response;\n");
    out.push_str("};\n");
    out
}

/// Render `headers` as the body of a JS/TS object literal, one `"key": "value",` per line at the
/// given `indent`. Keys and values are emitted as JSON string literals (via `serde_json`), which
/// are also valid JS/TS string literals — so any quotes in a CSP value are escaped correctly.
fn js_object_entries(headers: &[(&'static str, String)], indent: &str) -> String {
    let mut out = String::new();
    for (name, value) in headers {
        out.push_str(indent);
        out.push_str(&js_string(name));
        out.push_str(": ");
        out.push_str(&js_string(value));
        out.push_str(",\n");
    }
    out
}

/// A JSON-encoded (hence safely double-quoted and escaped) string literal.
fn js_string(s: &str) -> String {
    serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string())
}

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

    fn default_cfg() -> Config {
        Config::default()
    }

    #[test]
    fn target_parse_aliases_and_errors() {
        assert_eq!(Target::parse("_headers").unwrap(), Target::Headers);
        assert_eq!(Target::parse("NETLIFY").unwrap(), Target::Headers);
        assert_eq!(Target::parse("cloudflare-pages").unwrap(), Target::Headers);
        assert_eq!(Target::parse(" Vercel ").unwrap(), Target::Vercel);
        assert_eq!(
            Target::parse("vercel-middleware").unwrap(),
            Target::VercelMiddleware
        );
        assert_eq!(Target::parse("netlify-edge").unwrap(), Target::NetlifyEdge);
        assert!(Target::parse("nginx").is_err());
    }

    #[test]
    fn headers_file_lists_every_security_header() {
        let cfg = default_cfg();
        let out = generate(&cfg, Target::Headers);
        assert!(out.contains("/*\n"), "missing path pattern:\n{out}");
        // Every header from the shared source of truth must appear with its exact value.
        for (name, value) in crate::proxy::security_headers(&cfg.headers) {
            assert!(
                out.contains(&format!("  {name}: {value}\n")),
                "missing `{name}: {value}` in:\n{out}"
            );
        }
        // Spot-check a couple of concrete defaults.
        assert!(out.contains("  X-Content-Type-Options: nosniff\n"));
        assert!(out.contains("  Content-Security-Policy: default-src 'self'\n"));
    }

    #[test]
    fn headers_file_honors_report_only_and_toggles() {
        let cfg = Config {
            headers: HeadersCfg {
                hsts: false,
                csp_report_only: true,
                ..HeadersCfg::default()
            },
            ..Config::default()
        };
        let out = generate(&cfg, Target::Headers);
        assert!(out.contains("Content-Security-Policy-Report-Only:"));
        assert!(!out.contains("\n  Content-Security-Policy:"));
        assert!(!out.contains("Strict-Transport-Security"));
    }

    #[test]
    fn vercel_json_is_valid_json_with_all_headers() {
        let cfg = default_cfg();
        let out = generate(&cfg, Target::Vercel);
        let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
        let rule = &parsed["headers"][0];
        assert_eq!(rule["source"], "/(.*)");
        let arr = rule["headers"].as_array().expect("headers array");
        // Reconstruct name->value and check it matches the shared source of truth exactly.
        let mut got = std::collections::HashMap::new();
        for e in arr {
            got.insert(
                e["key"].as_str().unwrap().to_string(),
                e["value"].as_str().unwrap().to_string(),
            );
        }
        for (name, value) in crate::proxy::security_headers(&cfg.headers) {
            assert_eq!(got.get(name).map(String::as_str), Some(value.as_str()));
        }
    }

    #[test]
    fn middleware_and_edge_snippets_escape_csp_quotes() {
        // The default CSP `default-src 'self'` carries single quotes; emitting it inside a JS/TS
        // object literal must keep it intact (we use double-quoted JSON string literals).
        let cfg = default_cfg();
        for target in [Target::VercelMiddleware, Target::NetlifyEdge] {
            let out = generate(&cfg, target);
            assert!(
                out.contains(r#""Content-Security-Policy": "default-src 'self'""#),
                "CSP not emitted as a clean literal for {target:?}:\n{out}"
            );
            // Sanity: the snippet sets headers in a loop and is syntactically plausible.
            assert!(out.contains("Object.entries(SECURITY_HEADERS)"));
            assert!(out.contains(r#""X-Content-Type-Options": "nosniff""#));
        }
    }
}