cairnlang-core 0.5.0

Cairn core: content-addressed AST store, the single type/confidence/effect checker, projection renderer, and WASM lowering. Owns the model.
Documentation
//! T4 — session auth, the v0.3 forcing function's payoff, proven end
//! to end. A small auth module composed *entirely from framework
//! primitives* — `form_value` (parse the login post), `cookie`
//! (read the session id from the request `Cookie` header — the T3a
//! request-header lane), `db_query` (the session row in real SQLite),
//! `rand` (an opaque token), and `set_header` (the T3b `Resp` effect
//! that emits `Set-Cookie`). No middleware: `require_admin` is an
//! ordinary Cairn function a route calls — auth is an *explicit
//! guard*, visible and reviewed (Principle 1), not a hidden layer.
//!
//! The admin secret is a literal authored into the module: the AST is
//! the source and is reviewed (Principle 4), so the secret is explicit
//! and checked, not a hidden env read. A config/secret effect is a
//! recorded non-goal unless a real deployment forces it (Principle
//! 10) — this test is that honest v1 line.
//!
//! Proven: unauth → 403; wrong secret → no cookie; right secret →
//! 200 + `Set-Cookie` with an opaque token persisted in SQLite; that
//! cookie → the protected route returns the secret area; logout →
//! a clearing `Set-Cookie`; a stale/empty cookie → 403 again.

use cairn_core::{
    drain_resp_headers, lower, web, Confidence, Effect, ExprSpec, FunctionSpec,
    ModuleSpec, Param, Produces, StepSpec, Type,
};
use cairn_core::{serve_request_db, serve_request_db_h, Editor, Store};
use std::collections::BTreeSet;

fn r(n: &str) -> ExprSpec {
    ExprSpec::Ref(n.into())
}
fn lit(s: &str) -> ExprSpec {
    ExprSpec::Str(s.into())
}
fn num(n: i64) -> ExprSpec {
    ExprSpec::Lit(n)
}
fn cat(a: ExprSpec, b: ExprSpec) -> ExprSpec {
    ExprSpec::StrConcat(Box::new(a), Box::new(b))
}
fn cats(parts: Vec<ExprSpec>) -> ExprSpec {
    let mut it = parts.into_iter();
    let f = it.next().unwrap();
    it.fold(f, cat)
}
fn n2s(e: ExprSpec) -> ExprSpec {
    ExprSpec::NumberToStr(Box::new(e))
}
fn streq(a: ExprSpec, b: ExprSpec) -> ExprSpec {
    ExprSpec::StrEq(Box::new(a), Box::new(b))
}
fn field(b: ExprSpec, t: &str, f: &str) -> ExprSpec {
    ExprSpec::Field {
        base: Box::new(b),
        type_name: t.into(),
        field: f.into(),
    }
}
fn call(f: &str, a: Vec<ExprSpec>) -> ExprSpec {
    ExprSpec::Call {
        func: f.into(),
        args: a,
    }
}
fn iff(c: ExprSpec, t: ExprSpec, e: ExprSpec) -> ExprSpec {
    ExprSpec::If {
        cond: Box::new(c),
        then_branch: Box::new(t),
        else_branch: Box::new(e),
    }
}
fn dbq(sql: &str, params: Vec<ExprSpec>) -> ExprSpec {
    ExprSpec::DbQuery {
        sql: Box::new(lit(sql)),
        params: Box::new(if params.is_empty() {
            ExprSpec::ListEmpty { elem: Type::String }
        } else {
            ExprSpec::List(params)
        }),
    }
}
fn set_header(name: &str, value: ExprSpec) -> ExprSpec {
    ExprSpec::SetHeader {
        name: Box::new(lit(name)),
        value: Box::new(value),
    }
}
fn resp(status: i64, body: ExprSpec) -> ExprSpec {
    ExprSpec::Record {
        type_name: "Response".into(),
        fields: vec![
            ("status".into(), num(status)),
            ("body".into(), body),
        ],
    }
}
fn pr(name: &str) -> Param {
    Param {
        name: name.into(),
        ty: Type::Named("Request".into()),
        min_confidence: Confidence::External,
    }
}
fn ext(t: Type) -> Produces {
    Produces {
        ty: t,
        confidence: Confidence::External,
    }
}
fn step(b: &str, v: ExprSpec) -> StepSpec {
    StepSpec {
        binding: b.into(),
        value: v,
    }
}
fn effs(es: &[Effect]) -> BTreeSet<Effect> {
    es.iter().copied().collect()
}

const CREATE: &str = "CREATE TABLE IF NOT EXISTS sessions(tok TEXT)";
const ADMIN: &str = "s3cret"; // authored, reviewed — the v1 "config"

fn auth_module() -> ModuleSpec {
    // login(req): right secret -> persist an opaque token + Set-Cookie;
    // wrong -> "no", no cookie. Token computed unconditionally (rand is
    // cheap; an unused token on a failed login is harmless and simpler
    // than a conditional binding).
    let login = FunctionSpec {
        name: "login".into(),
        type_params: vec![],
        params: vec![pr("req")],
        produces: ext(Type::Named("Response".into())),
        requires: effs(&[Effect::Db, Effect::Rand, Effect::Resp]),
        on_failure: vec![],
        steps: vec![
            step("c", dbq(CREATE, vec![])),
            step(
                "sec",
                call(
                    "form_value",
                    vec![field(r("req"), "Request", "body"), lit("secret")],
                ),
            ),
            step("tok", n2s(ExprSpec::Rand)),
        ],
        result: iff(
            streq(r("sec"), lit(ADMIN)),
            resp(
                200,
                cats(vec![
                    lit("in:"),
                    dbq(
                        "INSERT INTO sessions(tok) VALUES(?)",
                        vec![r("tok")],
                    ),
                    n2s(set_header(
                        "Set-Cookie",
                        cats(vec![
                            lit("sid="),
                            r("tok"),
                            lit("; HttpOnly; Path=/"),
                        ]),
                    )),
                ]),
            ),
            resp(200, lit("no")),
        ),
    };

    // require_admin(req) -> "ok" | "deny": the explicit guard. Reads
    // the session id from the Cookie header, checks it against SQLite.
    let require_admin = FunctionSpec {
        name: "require_admin".into(),
        type_params: vec![],
        params: vec![pr("req")],
        produces: ext(Type::String),
        requires: effs(&[Effect::Db]),
        on_failure: vec![],
        steps: vec![
            step("c", dbq(CREATE, vec![])),
            step("sid", call("cookie", vec![r("req"), lit("sid")])),
            step(
                "n",
                dbq(
                    "SELECT count(*) FROM sessions WHERE tok=?",
                    vec![r("sid")],
                ),
            ),
        ],
        result: iff(
            streq(r("sid"), lit("")),
            lit("deny"),
            iff(streq(r("n"), lit("1")), lit("ok"), lit("deny")),
        ),
    };

    // admin_only(req): the protected route — calls the guard, nothing
    // hidden. Db unions in from require_admin via the call.
    let admin_only = FunctionSpec {
        name: "admin_only".into(),
        type_params: vec![],
        params: vec![pr("req")],
        produces: ext(Type::Named("Response".into())),
        requires: effs(&[Effect::Db]),
        on_failure: vec![],
        steps: vec![step("g", call("require_admin", vec![r("req")]))],
        result: iff(
            streq(r("g"), lit("ok")),
            resp(200, lit("secret-area")),
            resp(403, lit("forbidden")),
        ),
    };

    // logout(req): a clearing Set-Cookie (the Resp effect again).
    let logout = FunctionSpec {
        name: "logout".into(),
        type_params: vec![],
        params: vec![pr("req")],
        produces: ext(Type::Named("Response".into())),
        requires: effs(&[Effect::Resp]),
        on_failure: vec![],
        steps: vec![],
        result: resp(
            200,
            cat(
                lit("out"),
                n2s(set_header(
                    "Set-Cookie",
                    lit("sid=; Path=/; Max-Age=0"),
                )),
            ),
        ),
    };

    ModuleSpec {
        name: "auth".into(),
        types: web::types(),
        functions: {
            let mut f = web::functions();
            f.extend([login, require_admin, admin_only, logout]);
            f
        },
    }
}

#[test]
fn the_session_auth_guard_works_end_to_end_over_real_sqlite() {
    let editor = Editor::new(Store::open_in_memory().unwrap());
    let (module, report) = editor.apply_module(&auth_module()).unwrap();
    assert!(
        report.ok(),
        "auth did not type-check: {:?}",
        report.violations
    );
    let wasm = lower(editor.store(), &module).unwrap();

    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let dbp = std::env::temp_dir()
        .join(format!("cairn_auth_{}_{nanos}.db", std::process::id()));
    let db = dbp.to_str().unwrap().to_string();
    let _ = std::fs::remove_file(&dbp);
    let _ = drain_resp_headers(); // clean seam

    // 1. No cookie → the guard denies.
    let r0 =
        serve_request_db(&wasm, "admin_only", &db, "GET", "/admin", "")
            .unwrap();
    assert_eq!(r0.status, 403);
    assert_eq!(r0.body, "forbidden");
    assert!(drain_resp_headers().is_empty());

    // 2. Wrong secret → "no", no Set-Cookie.
    let bad = serve_request_db(
        &wasm, "login", &db, "POST", "/login", "secret=nope",
    )
    .unwrap();
    assert_eq!(bad.body, "no");
    assert!(
        drain_resp_headers().is_empty(),
        "a failed login must not set a cookie"
    );

    // 3. Right secret → persisted token + Set-Cookie.
    let ok = serve_request_db(
        &wasm,
        "login",
        &db,
        "POST",
        "/login",
        &format!("secret={ADMIN}"),
    )
    .unwrap();
    assert!(ok.body.starts_with("in:"), "login body: {}", ok.body);
    let hs = drain_resp_headers();
    let (hn, hv) = hs
        .iter()
        .find(|(n, _)| n == "Set-Cookie")
        .expect("login emits Set-Cookie");
    assert_eq!(hn, "Set-Cookie");
    assert!(
        hv.starts_with("sid=") && hv.ends_with("; HttpOnly; Path=/"),
        "cookie attrs: {hv}"
    );
    let tok = hv["sid=".len()..hv.len() - "; HttpOnly; Path=/".len()]
        .to_string();
    assert!(!tok.is_empty(), "an opaque token was issued");

    // 4. That cookie → the protected route opens.
    let authed = serve_request_db_h(
        &wasm,
        "admin_only",
        &db,
        "GET",
        "/admin",
        "",
        &format!("Cookie: sid={tok}"),
    )
    .unwrap();
    assert_eq!(authed.status, 200, "valid session must pass the guard");
    assert_eq!(authed.body, "secret-area");
    let _ = drain_resp_headers();

    // 5. Logout emits a clearing Set-Cookie.
    let out =
        serve_request_db(&wasm, "logout", &db, "POST", "/logout", "")
            .unwrap();
    assert!(out.body.starts_with("out"), "logout body: {}", out.body);
    let cleared = drain_resp_headers();
    assert!(
        cleared
            .iter()
            .any(|(n, v)| n == "Set-Cookie" && v.contains("Max-Age=0")),
        "logout clears the cookie: {cleared:?}"
    );

    // 6. A forged / empty session id is denied (the SQLite check is
    // real — an unknown token is not a row).
    for spoof in ["Cookie: sid=", "Cookie: sid=deadbeefnotreal"] {
        let denied = serve_request_db_h(
            &wasm, "admin_only", &db, "GET", "/admin", "", spoof,
        )
        .unwrap();
        assert_eq!(denied.status, 403, "spoofed cookie {spoof:?}");
        let _ = drain_resp_headers();
    }

    let _ = std::fs::remove_file(&dbp);
}