ssg 0.0.44

A secure-by-default static site generator built in Rust. WCAG 2.2 AA validation, CSP/SRI hardening, native JS/CSS minification, automated CycloneDX SBOM, local LLM content pipeline, WebAssembly target, interactive islands, streaming compilation for 100K+ pages, 28-locale i18n, and one-command deployment.
Documentation
// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
// SPDX-License-Identifier: Apache-2.0 OR MIT

//! Vercel `vercel.json` headers-array emitter (issue #550 AC3).
//!
//! Vercel's deployment config doesn't tolerate JSON comments, so the
//! PQC documentation is carried as a top-level `_pqc_note` array of
//! strings (Vercel ignores keys it doesn't recognise). The
//! canonical `headers` array follows the Vercel schema verbatim:
//!
//! ```json
//! {
//!   "_pqc_note": ["…"],
//!   "headers": [
//!     {
//!       "source": "/(.*)",
//!       "headers": [
//!         { "key": "Strict-Transport-Security", "value": "…" },
//!//!       ]
//!     }
//!   ]
//! }
//! ```

use super::PQC_NOTE_LINES;
use serde_json::{json, Value};

/// Renders the Vercel JSON body.
///
/// # Errors
///
/// Returns `serde_json::Error` only if the produced `Value` fails to
/// serialise, which in practice never happens because the structure
/// is built from owned strings.
pub(super) fn render(
    headers: &[(String, String)],
) -> Result<String, serde_json::Error> {
    let headers_arr: Vec<Value> = headers
        .iter()
        .map(|(k, v)| json!({ "key": k, "value": v }))
        .collect();

    let pqc_note: Vec<Value> = PQC_NOTE_LINES
        .iter()
        .map(|s| Value::String((*s).into()))
        .collect();

    let config = json!({
        "_pqc_note": pqc_note,
        "headers": [
            {
                "source": "/(.*)",
                "headers": headers_arr,
            }
        ]
    });

    serde_json::to_string_pretty(&config)
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    use super::*;
    use crate::postprocess::edge_headers::merged_headers;
    use std::collections::BTreeMap;

    #[test]
    fn render_includes_pqc_note_field() {
        let body = render(&merged_headers(&BTreeMap::new())).unwrap();
        let parsed: Value = serde_json::from_str(&body).unwrap();
        let note = parsed.get("_pqc_note").and_then(|v| v.as_array()).unwrap();
        let joined: String = note
            .iter()
            .filter_map(|v| v.as_str())
            .collect::<Vec<_>>()
            .join(" ");
        assert!(joined.contains("X25519+ML-KEM-768"));
        assert!(joined.contains("vercel.com"));
    }

    #[test]
    fn render_uses_canonical_headers_array_shape() {
        let body = render(&merged_headers(&BTreeMap::new())).unwrap();
        let parsed: Value = serde_json::from_str(&body).unwrap();
        let group = parsed
            .get("headers")
            .and_then(|v| v.as_array())
            .and_then(|a| a.first())
            .unwrap();
        assert_eq!(group.get("source").and_then(|v| v.as_str()), Some("/(.*)"));
        let arr = group.get("headers").and_then(|v| v.as_array()).unwrap();
        assert_eq!(arr.len(), 5);
        for item in arr {
            assert!(item.get("key").is_some(), "each entry needs a key");
            assert!(item.get("value").is_some(), "each entry needs a value");
        }
    }

    #[test]
    fn render_emits_one_csp_entry_only() {
        // AC7: no duplicate Content-Security-Policy across the array.
        let body = render(&merged_headers(&BTreeMap::new())).unwrap();
        let parsed: Value = serde_json::from_str(&body).unwrap();
        let arr = parsed["headers"][0]["headers"].as_array().unwrap();
        let count = arr
            .iter()
            .filter(|h| {
                h.get("key").and_then(|k| k.as_str()).is_some_and(|s| {
                    s.eq_ignore_ascii_case("Content-Security-Policy")
                })
            })
            .count();
        assert_eq!(count, 1);
    }

    #[test]
    fn render_keys_are_in_baseline_order() {
        let body = render(&merged_headers(&BTreeMap::new())).unwrap();
        let parsed: Value = serde_json::from_str(&body).unwrap();
        let arr = parsed["headers"][0]["headers"].as_array().unwrap();
        let keys: Vec<&str> =
            arr.iter().map(|h| h["key"].as_str().unwrap()).collect();
        assert_eq!(
            keys,
            vec![
                "Strict-Transport-Security",
                "Content-Security-Policy",
                "X-Content-Type-Options",
                "Referrer-Policy",
                "Permissions-Policy",
            ]
        );
    }

    #[test]
    fn render_emits_pretty_printed_json() {
        let body = render(&merged_headers(&BTreeMap::new())).unwrap();
        // serde_json::to_string_pretty indents with two spaces;
        // assert at least one newline + two-space indent appears.
        assert!(body.contains("\n  "), "pretty output expected");
    }

    #[test]
    fn render_with_empty_headers_produces_valid_json() {
        let body = render(&[]).unwrap();
        let parsed: Value = serde_json::from_str(&body).unwrap();
        let arr = parsed["headers"][0]["headers"].as_array().unwrap();
        assert!(arr.is_empty());
    }

    #[test]
    fn render_pqc_note_lines_match_constant() {
        let body = render(&merged_headers(&BTreeMap::new())).unwrap();
        let parsed: Value = serde_json::from_str(&body).unwrap();
        let note = parsed.get("_pqc_note").and_then(|v| v.as_array()).unwrap();
        assert_eq!(note.len(), PQC_NOTE_LINES.len());
        for (i, line) in PQC_NOTE_LINES.iter().enumerate() {
            assert_eq!(note[i].as_str(), Some(*line));
        }
    }

    #[test]
    fn render_with_custom_headers_round_trips_through_serde() {
        let custom = vec![
            ("Strict-Transport-Security".to_string(), "x".to_string()),
            ("Custom-Key".to_string(), "value".to_string()),
        ];
        let body = render(&custom).unwrap();
        let parsed: Value = serde_json::from_str(&body).unwrap();
        let arr = parsed["headers"][0]["headers"].as_array().unwrap();
        assert_eq!(arr.len(), 2);
        assert_eq!(arr[1]["key"].as_str(), Some("Custom-Key"));
        assert_eq!(arr[1]["value"].as_str(), Some("value"));
    }

    #[test]
    fn render_source_route_matches_every_path() {
        let body = render(&merged_headers(&BTreeMap::new())).unwrap();
        let parsed: Value = serde_json::from_str(&body).unwrap();
        let source = parsed["headers"][0]["source"].as_str().unwrap();
        // Vercel regex matching every path is `/(.*)`.
        assert_eq!(source, "/(.*)");
    }

    #[test]
    fn render_with_special_characters_in_value_escapes_correctly() {
        let custom =
            vec![("Backslash-Test".to_string(), "a\"b\\c".to_string())];
        let body = render(&custom).unwrap();
        let parsed: Value = serde_json::from_str(&body).unwrap();
        let value = parsed["headers"][0]["headers"][0]["value"]
            .as_str()
            .unwrap();
        assert_eq!(value, "a\"b\\c");
    }
}