Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-forge/tests/token_never_leaks.rs

//! **Proving a negative: the token appears in exactly one place, and that place
//! is the request header it belongs in.**
//!
//! # Why this file exists at all
//!
//! Everything else in this crate is testable by asserting that something *is*
//! there. This is the one property that can only be tested by asserting that
//! something is **not**, and negatives rot: the code that leaks a credential is
//! never the code someone wrote on purpose, it is the `#[derive(Debug)]` added
//! six months later to a struct that had meanwhile gained a field, or the
//! `#[from] ureq::Error` that made an error conversion one line shorter.
//!
//! So the assertion is made against a token string that is trivially greppable
//! and is driven through **every** exit this crate has: each error variant's
//! `Display` and `Debug`, the `Debug` of the client itself, the panic message
//! that `unwrap()` on an error produces, and the successful return value.
//!
//! # Seen red first
//!
//! MEASURED against a draft whose `Transport` variant rendered ureq's own
//! `Display` instead of a static word — `cargo test -p gunnar-forge --test list
//! an_unreachable_forge`:
//!
//! ```text
//! ---- an_unreachable_forge_is_a_transport_error_naming_no_url stdout ----
//! thread 'an_unreachable_forge_is_a_transport_error_naming_no_url' panicked at
//!   crates/gunnar-forge/tests/list.rs:689:5:
//! an error must not carry the request: github: could not reach the forge
//!   (http://127.0.0.1:1/user/repos?per_page=100&page=1
//!    &affiliation=owner,collaborator,organization_member&sort=full_name:
//!    Connection Failed: Connect error: Connection refused (os error 111))
//! ```
//!
//! **The whole request, query string and all, in a message a caller will log.**
//! The URL is not the token today — but a URL in an error message is the
//! mechanism by which a token becomes one, the day somebody moves the
//! credential into a query parameter because a forge's documentation showed it
//! that way. The variant carries a `&'static str` chosen from a closed match
//! and cannot.
//!
//! # What this file cannot prove
//!
//! That `ureq` does not log the header itself, and that the OS does not page the
//! string to disk. Neither is in this crate's reach. What is in reach is that
//! *this crate* never copies the token anywhere, and that is what is asserted.

mod common;

use std::panic::AssertUnwindSafe;
use std::sync::{Arc, Mutex};

use gunnar_forge::testing::MockServer;
use gunnar_forge::{Forge, ForgeError, ListOptions};

/// Deliberately unmistakable. Any substring of it appearing in any rendering is
/// a failure, and a real token has the same shape.
const TOKEN: &str = "ghp_TOTALLYSECRETVALUE0000000000000000";

/// Every way a `ForgeError` can be turned into text.
fn renderings(err: &ForgeError) -> Vec<String> {
    vec![
        format!("{err}"),
        format!("{err:?}"),
        format!("{err:#?}"),
        panic_message_of_unwrap(err),
    ]
}

/// What a caller's `.unwrap()` actually prints, captured from a real panic.
///
/// Not `format!("{err:?}")` with a comment saying "this is what unwrap prints":
/// that would be asserting on our belief about `Result::unwrap` rather than on
/// `Result::unwrap`. The panic is the thing a developer pastes into a bug
/// report from a terminal that may be recorded, so it is the rendering that
/// most needs to be clean, and it is produced here rather than imagined.
fn panic_message_of_unwrap(err: &ForgeError) -> String {
    // The panic hook is PROCESS-wide and the tests of this file run on parallel threads: two
    // swapping it at once each captured the other's panic, or neither (MEASURED dwarf
    // 2026-09-29: 1 run in 200 of this binary failed, `Err(Unauthorized)` printed by a hook
    // another test had just restored). One swap at a time.
    static HOOK: Mutex<()> = Mutex::new(());
    let _one = HOOK.lock().unwrap_or_else(|p| p.into_inner());
    let captured = Arc::new(Mutex::new(String::new()));
    let sink = Arc::clone(&captured);
    let previous = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        *sink.lock().unwrap() = info.to_string();
    }));
    let outcome = std::panic::catch_unwind(AssertUnwindSafe(|| {
        let r: Result<(), &ForgeError> = Err(err);
        // clippy sees a `Result` it can prove is `Err` and offers to remove the
        // `unwrap`. Removing it removes the thing under test: the panic message
        // is what this function exists to capture.
        #[allow(clippy::unnecessary_literal_unwrap)]
        r.unwrap();
    }));
    std::panic::set_hook(previous);
    assert!(outcome.is_err(), "unwrapping an Err must panic");
    let msg = captured.lock().unwrap().clone();
    assert!(
        msg.contains("called `Result::unwrap()`"),
        "the panic hook did not capture the unwrap message: {msg}"
    );
    msg
}

fn assert_clean(what: &str, texts: &[String]) {
    for t in texts {
        assert!(!t.contains(TOKEN), "{what} leaked the whole token: {t}");
        // A prefix is enough to be dangerous — a truncated credential in a log
        // is still a credential in a log for anything that truncates at 20.
        assert!(
            !t.contains("ghp_"),
            "{what} leaked a token-shaped string: {t}"
        );
        assert!(
            !t.contains("TOTALLYSECRET"),
            "{what} leaked part of the token: {t}"
        );
    }
}

#[test]
fn no_error_variant_can_carry_the_token() {
    // Each of the five reachable variants, produced the way the crate really
    // produces them — through a real request against the double, not by
    // constructing the enum by hand. A hand-built variant would prove only that
    // the struct literal we typed has no token in it.
    let cases: Vec<(&str, ForgeError)> = vec![
        ("401", err_for(401, &[], r#"{"message":"Bad credentials"}"#)),
        (
            "403 rate limit",
            err_for(
                403,
                &[("Retry-After", "60")],
                r#"{"message":"rate limited"}"#,
            ),
        ),
        ("500", err_for(500, &[], r#"{"message":"boom"}"#)),
        ("malformed", err_for(200, &[], "not json at all")),
        (
            "transport",
            Forge::github_enterprise("http://127.0.0.1:1")
                .list_repos(TOKEN)
                .unwrap_err(),
        ),
    ];

    for (name, err) in &cases {
        assert_clean(name, &renderings(err));
    }
}

#[test]
fn the_too_many_pages_error_is_clean_too() {
    let s = MockServer::start();
    // The header has to ADVANCE — a repeated "next is page 2" is ended by the
    // engine's monotonicity check long before the cap, which is what
    // `list.rs::a_forge_that_repeats_the_same_next_page_stops_instead_of_spinning`
    // pins. Here the cap is the thing under test, so the forge must keep
    // credibly promising more.
    for next in 2..=4 {
        s.on(
            "GET",
            "/api/v4/projects",
            200,
            &[("X-Next-Page", &next.to_string())],
            &common::gitlab_page(&[common::Row::public("a")]),
        );
    }
    let err = Forge::gitlab_at(s.url())
        .with_options(ListOptions {
            max_pages: 2,
            ..Default::default()
        })
        .list_repos(TOKEN)
        .unwrap_err();
    assert!(matches!(err, ForgeError::TooManyPages { .. }));
    assert_clean("too many pages", &renderings(&err));
}

#[test]
fn the_client_itself_has_nothing_to_print() {
    // The strongest half of the argument: `Forge` never receives the token, so
    // there is no field for a derive, a span, or a panic to reach. This asserts
    // the shape rather than the redaction — a redacting `Debug` is one careless
    // edit away from not redacting.
    let f = Forge::github();
    let d = format!("{f:?}");
    assert_clean("Forge Debug", std::slice::from_ref(&d));
    assert!(d.contains("github"), "it should still be useful: {d}");
    assert!(d.contains("api.github.com"), "{d}");

    // And after a call that USED the token, it still has nothing.
    let s = MockServer::start();
    s.on_body("GET", "/user/repos", 200, "[]");
    let f = Forge::github_enterprise(s.url());
    f.list_repos(TOKEN).unwrap();
    assert_clean("Forge Debug after a call", &[format!("{f:?}")]);
}

#[test]
fn the_successful_result_carries_no_token_either() {
    let s = MockServer::start();
    s.on_body(
        "GET",
        "/user/repos",
        200,
        &common::github_page(&[common::Row::public("a"), common::Row::private("b")]),
    );
    let repos = Forge::github_enterprise(s.url()).list_repos(TOKEN).unwrap();
    let rendered: Vec<String> = repos.iter().map(|r| format!("{r:?}")).collect();
    assert_clean("Repo Debug", &rendered);
    // Not vacuous: the rows really are there.
    assert_eq!(repos.len(), 2);
}

#[test]
fn the_token_reaches_the_wire_exactly_once_and_only_as_a_header() {
    let s = MockServer::start();
    s.on_body("GET", "/user/repos", 200, "[]");
    Forge::github_enterprise(s.url()).list_repos(TOKEN).unwrap();

    let r = &s.recorded()[0];
    // Never in the URL. A token in a query string is written to every access
    // log, every proxy log and the browser history of anyone who pastes it —
    // and some forge documentation still shows the form.
    assert!(!r.path.contains(TOKEN), "token in the path: {}", r.path);
    assert!(!r.query.contains(TOKEN), "token in the query: {}", r.query);
    assert!(
        !r.query.contains("ghp_"),
        "token-shaped value in the query: {}",
        r.query
    );

    let carrying: Vec<&(String, String)> = r
        .headers
        .iter()
        .filter(|(_, v)| v.contains(TOKEN))
        .collect();
    assert_eq!(
        carrying.len(),
        1,
        "the token must be in exactly one header, found: {carrying:?}"
    );
    assert!(
        carrying[0].0.eq_ignore_ascii_case("authorization"),
        "and that header must be Authorization, not {}",
        carrying[0].0
    );
}

#[test]
fn a_redirect_does_not_hand_the_token_to_the_host_it_points_at() {
    // The classic exfiltration: a compromised (or merely misconfigured) forge
    // answers 302 to a host of its choosing, and an HTTP client that replays
    // the Authorization header delivers the user's credential there. ureq's
    // `RedirectAuthHeaders::Never` is set explicitly in `engine.rs` for this;
    // the test is here because it is a *default* we depend on, and a default is
    // exactly the kind of thing that changes in a minor version.
    let attacker = MockServer::start();
    attacker.on_body("GET", "/collect", 200, "[]");

    let forge = MockServer::start();
    forge.on(
        "GET",
        "/user/repos",
        302,
        &[("Location", &format!("{}/collect", attacker.url()))],
        "",
    );

    // Whether the call succeeds is not the point and is not asserted: what
    // matters is what the attacker received.
    let _ = Forge::github_enterprise(forge.url()).list_repos(TOKEN);

    let seen = attacker.recorded();
    assert_eq!(
        seen.len(),
        1,
        "the redirect was followed, so the test is not vacuous"
    );
    let leaked: Vec<&(String, String)> = seen[0]
        .headers
        .iter()
        .filter(|(_, v)| v.contains(TOKEN) || v.contains("ghp_"))
        .collect();
    assert!(
        leaked.is_empty(),
        "the token was replayed to the redirect target: {leaked:?}"
    );
}

/// Drive one canned response through the crate and hand back the error.
fn err_for(status: u16, headers: &[(&str, &str)], body: &str) -> ForgeError {
    let s = MockServer::start();
    s.on("GET", "/user/repos", status, headers, body);
    Forge::github_enterprise(s.url())
        .list_repos(TOKEN)
        .expect_err("this fixture is supposed to fail")
}