#![cfg(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "gitee"
))]
use std::sync::{Arc, Mutex};
use std::time::Duration;
use self_update::http_client::{HeaderMap, HttpClient, HttpResponse};
const SECRET: &str = "secret-env-token-value";
mod capture {
use std::sync::{Mutex, OnceLock};
struct CaptureLogger;
static LOGGER: CaptureLogger = CaptureLogger;
fn buffer() -> &'static Mutex<Vec<String>> {
static BUF: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
BUF.get_or_init(|| Mutex::new(Vec::new()))
}
impl log::Log for CaptureLogger {
fn enabled(&self, _: &log::Metadata<'_>) -> bool {
true
}
fn log(&self, record: &log::Record<'_>) {
buffer().lock().unwrap().push(record.args().to_string());
}
fn flush(&self) {}
}
const SENTINEL: &str = "auth-token-env-host-warning-sentinel";
pub fn records(f: impl FnOnce()) -> Vec<String> {
static INIT: OnceLock<()> = OnceLock::new();
INIT.get_or_init(|| {
log::set_logger(&LOGGER).expect("this test binary owns the global logger");
log::set_max_level(log::LevelFilter::Trace);
});
buffer().lock().unwrap().clear();
log::warn!("{SENTINEL}");
f();
let out = buffer().lock().unwrap().clone();
assert!(
out.iter().any(|r| r.contains(SENTINEL)),
"log capture is not active, so a 'did not warn' assertion would pass vacuously"
);
out.into_iter()
.filter(|r| !r.contains(SENTINEL))
.collect::<Vec<_>>()
}
}
mod wire {
use super::{Duration, HeaderMap, HttpClient, HttpResponse};
use std::sync::{Arc, Mutex};
pub struct CannedResponse;
impl HttpResponse for CannedResponse {
fn headers(&self) -> &HeaderMap {
static EMPTY: std::sync::OnceLock<HeaderMap> = std::sync::OnceLock::new();
EMPTY.get_or_init(HeaderMap::new)
}
fn body(self: Box<Self>) -> Box<dyn std::io::Read> {
Box::new(std::io::Cursor::new(b"[]".to_vec()))
}
}
pub struct AuthRecorder(pub Arc<Mutex<Vec<Option<String>>>>);
impl HttpClient for AuthRecorder {
fn get(
&self,
_url: &str,
headers: &HeaderMap,
_timeout: Option<Duration>,
) -> self_update::Result<Box<dyn HttpResponse>> {
self.0.lock().unwrap().push(
headers
.get(self_update::http::header::AUTHORIZATION)
.map(|v| v.to_str().expect("a header value is ASCII").to_string()),
);
Ok(Box::new(CannedResponse))
}
}
}
fn captured_with_header(f: impl FnOnce(Arc<dyn HttpClient>)) -> (Vec<String>, Option<String>) {
let seen: Arc<Mutex<Vec<Option<String>>>> = Arc::new(Mutex::new(Vec::new()));
let seen_for_closure = seen.clone();
let records = capture::records(move || {
f(Arc::new(wire::AuthRecorder(seen_for_closure)));
});
let seen = seen.lock().unwrap();
assert_eq!(
seen.len(),
1,
"exactly one request must have gone through the transport, got {seen:?}"
);
(records, seen[0].clone())
}
const WARNING: &str = "resolved from the environment";
#[cfg(any(feature = "github", feature = "gitlab", feature = "gitee"))]
fn warned_about(records: &[String], host: &str, canonical: &str) -> bool {
for record in records {
assert!(
!record.contains(SECRET),
"a log record leaked the auth token: {record}"
);
}
records
.iter()
.any(|r| r.contains(WARNING) && r.contains(host) && r.contains(canonical))
}
#[cfg(feature = "gitea")]
fn withheld_about(records: &[String], host: &str) -> bool {
for record in records {
assert!(
!record.contains(SECRET),
"a log record leaked the auth token: {record}"
);
}
records
.iter()
.any(|r| r.contains(WARNING) && r.contains("withholding") && r.contains(host))
}
fn warned_at_all(records: &[String]) -> bool {
for record in records {
assert!(
!record.contains(SECRET),
"a log record leaked the auth token: {record}"
);
}
records.iter().any(|r| r.contains(WARNING))
}
#[test]
fn build_warns_when_an_env_sourced_token_is_bound_to_a_non_canonical_host() {
unsafe {
std::env::set_var("GH_TOKEN", SECRET);
std::env::set_var("GITLAB_TOKEN", SECRET);
std::env::set_var("GITEA_TOKEN", SECRET);
std::env::set_var("GITEE_TOKEN", SECRET);
}
#[cfg(feature = "github")]
{
use self_update::backends::github;
const ENTERPRISE: &str = "https://github.enterprise.test/api/v3";
let (records, header) = captured_with_header(|client| {
let _ = github::Update::configure()
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.api_base_url(ENTERPRISE)
.auth_token_from_env()
.http_client(client)
.build()
.unwrap()
.get_latest_release();
});
assert!(
warned_about(&records, "github.enterprise.test", "api.github.com"),
"github's UpdateBuilder::build() must reach the guard, got: {records:?}"
);
assert_eq!(
header.as_deref(),
Some("token secret-env-token-value"),
"D4: warn-and-send means the env token must still reach the wire, not just log a warning"
);
let (records, header) = captured_with_header(|client| {
let _ = github::ReleaseList::configure()
.repo_owner("o")
.repo_name("r")
.api_base_url(ENTERPRISE)
.auth_token_from_env()
.http_client(client)
.build()
.unwrap()
.fetch();
});
assert!(
warned_about(&records, "github.enterprise.test", "api.github.com"),
"github's ReleaseListBuilder::build() must reach the guard, got: {records:?}"
);
assert_eq!(
header.as_deref(),
Some("token secret-env-token-value"),
"D4: warn-and-send means the env token must still reach the wire, not just log a warning"
);
let records = capture::records(|| {
github::Update::configure()
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.auth_token_from_env()
.build()
.unwrap();
});
assert!(
!warned_at_all(&records),
"the default api.github.com must not warn, got: {records:?}"
);
let records = capture::records(|| {
github::Update::configure()
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.api_base_url(ENTERPRISE)
.auth_token_from_env()
.auth_token_from_env()
.build()
.unwrap();
});
assert!(
warned_about(&records, "github.enterprise.test", "api.github.com"),
"calling auth_token_from_env() twice must leave the token env-sourced, got: {records:?}"
);
for order in ["explicit then env", "env then explicit"] {
let records = capture::records(|| {
let mut b = github::Update::configure();
b.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.api_base_url(ENTERPRISE);
if order == "explicit then env" {
b.auth_token("explicit").auth_token_from_env();
} else {
b.auth_token_from_env().auth_token("explicit");
}
b.build().unwrap();
});
assert!(
!warned_at_all(&records),
"an explicit token must clear the env-sourced flag ({order}), got: {records:?}"
);
}
let (records, header) = captured_with_header(|client| {
let _ = github::Update::configure()
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.api_base_url(ENTERPRISE)
.allow_auth_host("github.enterprise.test")
.auth_token_from_env()
.http_client(client)
.build()
.unwrap()
.get_latest_release();
});
assert!(
!warned_at_all(&records),
"an acknowledged host must not warn, even though it is not canonical, got: {records:?}"
);
assert_eq!(
header.as_deref(),
Some("token secret-env-token-value"),
"acknowledging the host must silence the warning WITHOUT withholding the token"
);
let (records, header) = captured_with_header(|client| {
let _ = github::Update::configure()
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.api_base_url(ENTERPRISE)
.allow_auth_host("cdn.enterprise.test")
.auth_token_from_env()
.http_client(client)
.build()
.unwrap()
.get_latest_release();
});
assert!(
warned_about(&records, "github.enterprise.test", "api.github.com"),
"acknowledging a different host must not silence the warning, got: {records:?}"
);
assert_eq!(
header.as_deref(),
Some("token secret-env-token-value"),
"and warn-and-send still means sent"
);
for order in ["explicit then env", "env then explicit"] {
let records = capture::records(|| {
let mut b = github::ReleaseList::configure();
b.repo_owner("o").repo_name("r").api_base_url(ENTERPRISE);
if order == "explicit then env" {
b.auth_token("explicit").auth_token_from_env();
} else {
b.auth_token_from_env().auth_token("explicit");
}
b.build().unwrap();
});
assert!(
!warned_at_all(&records),
"ReleaseList: an explicit token must clear the flag ({order}), got: {records:?}"
);
}
}
#[cfg(feature = "gitlab")]
{
use self_update::backends::gitlab;
const SELF_HOSTED: &str = "https://gitlab.enterprise.test";
let (records, header) = captured_with_header(|client| {
let _ = gitlab::Update::configure()
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.host(SELF_HOSTED)
.auth_token_from_env()
.http_client(client)
.build()
.unwrap()
.get_latest_release();
});
assert!(
warned_about(&records, "gitlab.enterprise.test", "gitlab.com"),
"gitlab's UpdateBuilder::build() must reach the guard, got: {records:?}"
);
assert_eq!(
header.as_deref(),
Some("Bearer secret-env-token-value"),
"D4: warn-and-send means the env token must still reach the wire, not just log a warning"
);
let (records, header) = captured_with_header(|client| {
let _ = gitlab::ReleaseList::configure()
.repo_owner("o")
.repo_name("r")
.host(SELF_HOSTED)
.auth_token_from_env()
.http_client(client)
.build()
.unwrap()
.fetch();
});
assert!(
warned_about(&records, "gitlab.enterprise.test", "gitlab.com"),
"gitlab's ReleaseListBuilder::build() must reach the guard, got: {records:?}"
);
assert_eq!(
header.as_deref(),
Some("Bearer secret-env-token-value"),
"D4: warn-and-send means the env token must still reach the wire, not just log a warning"
);
let records = capture::records(|| {
gitlab::Update::configure()
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.auth_token_from_env()
.build()
.unwrap();
gitlab::ReleaseList::configure()
.repo_owner("o")
.repo_name("r")
.auth_token_from_env()
.build()
.unwrap();
});
assert!(
!warned_at_all(&records),
"the default gitlab.com must not warn, got: {records:?}"
);
for order in ["explicit then env", "env then explicit"] {
let records = capture::records(|| {
let mut b = gitlab::ReleaseList::configure();
b.repo_owner("o").repo_name("r").host(SELF_HOSTED);
if order == "explicit then env" {
b.auth_token("explicit").auth_token_from_env();
} else {
b.auth_token_from_env().auth_token("explicit");
}
b.build().unwrap();
});
assert!(
!warned_at_all(&records),
"gitlab: an explicit token must clear the flag ({order}), got: {records:?}"
);
}
}
#[cfg(feature = "gitee")]
{
use self_update::backends::gitee;
const MIRROR: &str = "https://gitee.mirror.test";
let (records, header) = captured_with_header(|client| {
let _ = gitee::Update::configure()
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.host(MIRROR)
.auth_token_from_env()
.http_client(client)
.build()
.unwrap()
.get_latest_release();
});
assert!(
warned_about(&records, "gitee.mirror.test", "gitee.com"),
"gitee's UpdateBuilder::build() must reach the guard, got: {records:?}"
);
assert_eq!(
header.as_deref(),
Some("Bearer secret-env-token-value"),
"D4: warn-and-send means the env token must still reach the wire, not just log a warning"
);
let (records, header) = captured_with_header(|client| {
let _ = gitee::ReleaseList::configure()
.repo_owner("o")
.repo_name("r")
.host(MIRROR)
.auth_token_from_env()
.http_client(client)
.build()
.unwrap()
.fetch();
});
assert!(
warned_about(&records, "gitee.mirror.test", "gitee.com"),
"gitee's ReleaseListBuilder::build() must reach the guard, got: {records:?}"
);
assert_eq!(
header.as_deref(),
Some("Bearer secret-env-token-value"),
"D4: warn-and-send means the env token must still reach the wire, not just log a warning"
);
let records = capture::records(|| {
gitee::Update::configure()
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.auth_token_from_env()
.build()
.unwrap();
gitee::ReleaseList::configure()
.repo_owner("o")
.repo_name("r")
.auth_token_from_env()
.build()
.unwrap();
});
assert!(
!warned_at_all(&records),
"the default gitee.com must not warn, got: {records:?}"
);
for order in ["explicit then env", "env then explicit"] {
let records = capture::records(|| {
let mut b = gitee::ReleaseList::configure();
b.repo_owner("o").repo_name("r").host(MIRROR);
if order == "explicit then env" {
b.auth_token("explicit").auth_token_from_env();
} else {
b.auth_token_from_env().auth_token("explicit");
}
b.build().unwrap();
});
assert!(
!warned_at_all(&records),
"gitee: an explicit token must clear the flag ({order}), got: {records:?}"
);
}
}
#[cfg(feature = "gitea")]
{
use self_update::backends::gitea;
const UNACKNOWLEDGED: &str = "https://gitea.example.test";
let mut upd = gitea::Update::configure();
upd.host(UNACKNOWLEDGED)
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.auth_token_from_env();
assert!(
upd.has_auth_token(),
"GITEA_TOKEN must still be picked up before build() decides whether to withhold it"
);
let (records, header) = captured_with_header(|client| {
let _ = upd
.http_client(client)
.build()
.unwrap()
.get_latest_release();
});
assert!(
withheld_about(&records, "gitea.example.test"),
"gitea's UpdateBuilder::build() must warn that the token is withheld, got: {records:?}"
);
assert_eq!(
header, None,
"D4: an unacknowledged host must WITHHOLD the token -- no Authorization header on the wire"
);
let mut list = gitea::ReleaseList::configure();
list.host(UNACKNOWLEDGED)
.repo_owner("o")
.repo_name("r")
.auth_token_from_env();
assert!(
list.has_auth_token(),
"GITEA_TOKEN must still be picked up before build() decides whether to withhold it"
);
let (records, header) = captured_with_header(|client| {
let _ = list.http_client(client).build().unwrap().fetch();
});
assert!(
withheld_about(&records, "gitea.example.test"),
"gitea's ReleaseListBuilder::build() must warn that the token is withheld, got: {records:?}"
);
assert_eq!(
header, None,
"D4: an unacknowledged host must WITHHOLD the token -- no Authorization header on the wire"
);
let mut upd = gitea::Update::configure();
upd.host(UNACKNOWLEDGED)
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.auth_token_from_env()
.allow_auth_host("gitea.example.test");
assert!(upd.has_auth_token());
let (records, header) = captured_with_header(|client| {
let _ = upd
.http_client(client)
.build()
.unwrap()
.get_latest_release();
});
assert!(
!warned_at_all(&records),
"acknowledging the host via allow_auth_host must silence the warning, got: {records:?}"
);
assert_eq!(
header.as_deref(),
Some("token secret-env-token-value"),
"an acknowledged host must SEND the env-sourced token"
);
let mut list = gitea::ReleaseList::configure();
list.host(UNACKNOWLEDGED)
.repo_owner("o")
.repo_name("r")
.auth_token_from_env()
.allow_auth_host("gitea.example.test");
assert!(list.has_auth_token());
let (records, header) = captured_with_header(|client| {
let _ = list.http_client(client).build().unwrap().fetch();
});
assert!(
!warned_at_all(&records),
"acknowledging the host via allow_auth_host must silence the warning, got: {records:?}"
);
assert_eq!(
header.as_deref(),
Some("token secret-env-token-value"),
"an acknowledged host must SEND the env-sourced token"
);
}
}