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")
}