use std::fmt::Display;
use std::io::{self, IsTerminal as _, Read, Write};
use std::sync::Arc;
use std::time::Duration;
use chrono::{DateTime, Utc};
use runner_manager_domain::model::StartMode;
use runner_manager_domain::store::Store;
use runner_manager_github::device_flow::{DeviceAuthorization, DeviceFlow, DeviceFlowError};
use runner_manager_github::{
AuthenticatedClient, CredentialRenewal, CredentialSource, GithubError, Installation,
InstallationDiscovery, RepositorySelection, TokioSleeper, UserAccessToken,
};
use runner_manager_platform::secrets::{Removal, SecretStore, SecretStoreError};
use secrecy::zeroize::Zeroize as _;
use secrecy::{ExposeSecret as _, SecretString};
use super::{
AuthCommand, AuthReceiveArgs, AuthStatusArgs, CliError, Context, Failure, NO_OPERATOR_REMEDY,
Styling, open_in_browser, write_failed,
};
pub const CRITICAL_PERMISSION: &str = "Administration: Read and write";
const PERMISSIONS: [(&str, &str, &str); 4] = [
(
"Repository -> Administration",
"Read and write",
"Registering a just-in-time runner at repository scope.",
),
(
"Repository -> Actions",
"Read",
"Counting in-progress workflow runs.",
),
(
"Repository -> Metadata",
"Read",
"Mandatory for any repository access.",
),
(
"Organization -> Self-hosted runners",
"Read and write",
"Registering a just-in-time runner at organization scope.",
),
];
pub fn write_grant_consequences(out: &mut dyn Write) -> io::Result<()> {
writeln!(out)?;
writeln!(
out,
"`{CRITICAL_PERMISSION}` is NOT a narrow self-hosted-runner permission."
)?;
writeln!(
out,
"The same grant also permits DELETING, RENAMING and TRANSFERRING the repository, and"
)?;
writeln!(
out,
"adding and removing collaborators. Watching grants exactly the same set."
)?;
writeln!(out)?;
Ok(())
}
pub fn write_permissions(out: &mut dyn Write) -> io::Result<()> {
writeln!(
out,
"Signing in installs this project's published GitHub App on the repositories or"
)?;
writeln!(
out,
"organizations you choose. That App declares one permission set, the same for every"
)?;
writeln!(out, "user:")?;
writeln!(out)?;
for (permission, level, why) in PERMISSIONS {
writeln!(out, " {permission:<36} {level:<15} {why}")?;
}
write_grant_consequences(out)?;
writeln!(
out,
"A monitor-only target grants the same set: an App grants its whole declared set on"
)?;
writeln!(
out,
"installation, and there is no per-installation subset. Organization scope is"
)?;
writeln!(
out,
"narrower -- registration there is authorized by `Organization -> Self-hosted"
)?;
writeln!(
out,
"runners: Read and write` alone -- and is the safer choice where both work."
)?;
writeln!(out)?;
writeln!(
out,
"Revoke by uninstalling the App, or by revoking its authorization, in your GitHub"
)?;
writeln!(out, "settings: https://github.com/settings/installations")?;
Ok(())
}
pub const ONBOARDING_ACTIONS: usize = 3;
fn write_action_one(out: &mut dyn Write, styling: Styling) -> io::Result<()> {
writeln!(
out,
"{} you ran `runner-manager auth login`.",
styling.step(&format!("Action 1 of {ONBOARDING_ACTIONS} (done):"))
)
}
fn write_action_two(
out: &mut dyn Write,
styling: Styling,
verification_url: &dyn Display,
user_code: &str,
expires_in: Duration,
opened: bool,
) -> io::Result<()> {
let url = verification_url.to_string();
writeln!(out)?;
writeln!(
out,
"{} enter this code at {}{}",
styling.step(&format!("Action 2 of {ONBOARDING_ACTIONS}:")),
styling.url(&url),
if opened { " (opened for you):" } else { ":" }
)?;
writeln!(out)?;
writeln!(out, " {}", styling.code(&format!(" {user_code} ")))?;
writeln!(out)?;
writeln!(
out,
"Enter it only on that page; expires in ~{} min. Waiting for approval...",
expires_in.as_secs() / 60
)?;
Ok(())
}
fn write_action_three(
out: &mut dyn Write,
styling: Styling,
install_url: &dyn Display,
) -> io::Result<()> {
let url = install_url.to_string();
let opened = open_in_browser(&url, styling);
writeln!(out)?;
writeln!(
out,
"{} install the App at {}{}",
styling.step(&format!("Action 3 of {ONBOARDING_ACTIONS}:")),
styling.url(&url),
if opened { " (opened for you)." } else { "." }
)?;
writeln!(
out,
"Choose only the repositories you want this host to serve."
)?;
Ok(())
}
fn write_action_three_already_done(out: &mut dyn Write, styling: Styling) -> io::Result<()> {
writeln!(out)?;
writeln!(
out,
"{} the App is already installed; nothing to choose.",
styling.step(&format!("Action 3 of {ONBOARDING_ACTIONS} (done):"))
)
}
pub fn dispatch(
context: &Context,
command: &AuthCommand,
styling: Styling,
out: &mut dyn Write,
) -> Result<(), CliError> {
match command {
AuthCommand::Login(a) => login(context, a.start_at.map(Into::into), a.list, styling, out),
AuthCommand::Status(a) => status(context, a, styling, out),
AuthCommand::Logout => logout(context, out),
AuthCommand::Receive(a) => receive(context, a, out),
}
}
fn write_store_choice(out: &mut dyn Write, mode: StartMode, chosen: bool) -> io::Result<()> {
let scope = match mode {
StartMode::Boot => "machine-scoped",
StartMode::Login => "your own",
};
if chosen {
writeln!(out, "Credential store: {scope} (start mode {mode}).")
} else {
writeln!(
out,
"Credential store: {scope} (start mode {mode}, assumed; `--start-at login` to change)."
)
}
}
#[derive(Debug)]
pub struct StoringRenewal {
flow: DeviceFlow,
secrets: Arc<dyn SecretStore>,
}
impl StoringRenewal {
#[must_use]
pub fn new(flow: DeviceFlow, secrets: Arc<dyn SecretStore>) -> Self {
Self { flow, secrets }
}
}
#[async_trait::async_trait]
impl CredentialRenewal for StoringRenewal {
async fn renew(&self, refresh_token: &SecretString) -> Result<UserAccessToken, String> {
let fresh = self
.flow
.refresh(refresh_token)
.await
.map_err(|source| format!("the refresh exchange failed: {source}"))?;
self.secrets
.store(&fresh.to_stored_document())
.map_err(|source| {
format!("the renewed credential could not be stored, so it was not used: {source}")
})?;
Ok(fresh)
}
}
#[derive(Debug)]
pub struct StoredCredential {
secrets: Arc<dyn SecretStore>,
}
impl StoredCredential {
#[must_use]
pub fn new(secrets: Arc<dyn SecretStore>) -> Self {
Self { secrets }
}
}
impl CredentialSource for StoredCredential {
fn reload(&self) -> Option<UserAccessToken> {
match self.secrets.load() {
Ok(Some(secret)) => Some(UserAccessToken::from_stored(secret)),
Ok(None) => None,
Err(source) => {
tracing::debug!(%source, "the credential store could not be re-read");
None
}
}
}
}
pub fn login(
context: &Context,
requested_mode: Option<StartMode>,
list: bool,
styling: Styling,
out: &mut dyn Write,
) -> Result<(), CliError> {
let failed = write_failed("this sign-in");
let app = context.app_registration()?;
let flow = DeviceFlow::new(app.clone(), context.endpoints().clone())
.map_err(|source| device_flow_failure(&source))?;
let runtime = super::runtime()?;
let store = context.store()?;
let recorded = context.recorded_start_mode(&store)?;
let start_mode = requested_mode.unwrap_or(recorded);
let secrets = context.secret_store(start_mode)?;
write_store_choice(out, start_mode, requested_mode.is_some()).map_err(failed)?;
if let Some(mode) = requested_mode {
record_start_mode(context, &store, recorded, mode)?;
}
let resumable = match secrets.load() {
Ok(existing) => existing,
Err(_) => {
writeln!(
out,
"\nThe credential already there could not be read, so this replaces it rather \
than resuming it. `auth status` says why."
)
.map_err(failed)?;
None
}
};
if let Some(secret) = resumable
&& let CredentialState::Authenticated(discovery) = credential_state_of(context, secret)?
{
writeln!(out, "Already signed in, so no new code is needed.").map_err(failed)?;
write_discovery(out, styling, &discovery, true, list).map_err(failed)?;
return Ok(());
}
write_action_one(out, styling).map_err(failed)?;
let token = acquire_user_credential(&flow, &runtime, styling, out)?;
secrets
.store(&token.to_stored_document())
.map_err(|source| secret_store_failure(&source))?;
let client = AuthenticatedClient::new(context.endpoints().clone(), token, context.clock())
.map_err(|source| github_failure(&source))?;
let discovery = runtime
.block_on(client.discover_installations(&app))
.map_err(|source| github_failure(&source))?;
if matches!(discovery, InstallationDiscovery::Installed(_)) {
write_action_three_already_done(out, styling).map_err(failed)?;
}
writeln!(
out,
"\nSigned in. The token is in the {}-scoped store and nowhere else.",
secrets.scope()
)
.map_err(failed)?;
write_discovery(out, styling, &discovery, true, list).map_err(failed)?;
Ok(())
}
fn record_start_mode(
context: &Context,
store: &dyn Store,
recorded: StartMode,
chosen: StartMode,
) -> Result<(), CliError> {
if chosen == recorded {
return Ok(());
}
let mut host = super::host::local_host_or_create(context, store)?;
host.service_start_mode = chosen;
store.put_host(&host).map_err(|source| {
CliError::new(
Failure::LocalState,
format!("cannot record the start mode this sign-in used: {source}"),
)
})
}
fn write_login_prompt(
out: &mut dyn Write,
styling: Styling,
flow: &DeviceFlow,
authorization: &DeviceAuthorization,
) -> io::Result<()> {
let url = flow.verification_url().to_string();
let opened = open_in_browser(&url, styling);
write_action_two(
out,
styling,
&url,
authorization.user_code(),
authorization.expires_in(),
opened,
)
}
pub fn acquire_user_credential(
flow: &DeviceFlow,
runtime: &tokio::runtime::Runtime,
styling: Styling,
out: &mut dyn Write,
) -> Result<UserAccessToken, CliError> {
let failed = write_failed("this sign-in");
let authorization = runtime
.block_on(flow.start())
.map_err(|source| device_flow_failure(&source))?;
write_login_prompt(out, styling, flow, &authorization).map_err(failed)?;
out.flush().map_err(failed)?;
runtime
.block_on(flow.complete(&authorization, &TokioSleeper))
.map_err(|source| device_flow_failure(&source))
}
#[allow(
dead_code,
reason = "the caller is `b2-wsl-cli-orchestration`; `b1` owns this seam"
)]
pub trait SecretSink {
fn send(&mut self, document: &SecretString) -> Result<(), SecretSinkError>;
}
#[allow(
dead_code,
reason = "the caller is `b2-wsl-cli-orchestration`; `b1` owns this seam"
)]
#[derive(Debug, thiserror::Error)]
pub enum SecretSinkError {
#[error("the credential could not be delivered to {destination}: {reason}")]
Undeliverable { destination: String, reason: String },
#[error("{destination} received the credential and refused it: {reason}")]
Refused { destination: String, reason: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BrokeredCredential {
pub renewable: bool,
pub access_expires_at: Option<DateTime<Utc>>,
pub refresh_expires_at: Option<DateTime<Utc>>,
}
impl BrokeredCredential {
#[must_use]
pub fn of(token: &UserAccessToken) -> Self {
let renewal = token.renewal();
Self {
renewable: renewal.is_some(),
access_expires_at: renewal.and_then(|r| r.access_expires_at),
refresh_expires_at: renewal.and_then(|r| r.refresh_expires_at),
}
}
}
impl Display for BrokeredCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.renewable {
write!(f, "It renews itself")?;
match (self.access_expires_at, self.refresh_expires_at) {
(Some(access), Some(refresh)) => write!(
f,
": the access token expires {access}, the refresh token {refresh}."
),
(Some(access), None) => write!(f, ": the access token expires {access}."),
(None, Some(refresh)) => write!(f, ": the refresh token expires {refresh}."),
(None, None) => write!(f, ", and GitHub stated no expiry."),
}
} else {
write!(
f,
"It carries no renewal half, so only an interactive sign-in replaces it."
)
}
}
}
#[allow(
dead_code,
reason = "the caller is `b2-wsl-cli-orchestration`; `b1` owns this seam"
)]
pub fn broker_user_credential(
context: &Context,
styling: Styling,
out: &mut dyn Write,
sink: &mut dyn SecretSink,
) -> Result<BrokeredCredential, CliError> {
let app = context.app_registration()?;
let flow = DeviceFlow::new(app, context.endpoints().clone())
.map_err(|source| device_flow_failure(&source))?;
let runtime = super::runtime()?;
let token = acquire_user_credential(&flow, &runtime, styling, out)?;
deliver_user_credential(&token, sink)
}
#[allow(
dead_code,
reason = "the caller is `b2-wsl-cli-orchestration`; `b1` owns this seam"
)]
pub fn deliver_user_credential(
token: &UserAccessToken,
sink: &mut dyn SecretSink,
) -> Result<BrokeredCredential, CliError> {
let metadata = BrokeredCredential::of(token);
sink.send(&token.to_stored_document())
.map_err(|source| secret_sink_failure(&source))?;
Ok(metadata)
}
pub const RECEIVED_DOCUMENT_LIMIT: usize = 64 * 1024;
pub fn receive(
context: &Context,
args: &AuthReceiveArgs,
out: &mut dyn Write,
) -> Result<(), CliError> {
refuse_a_terminal(io::stdin().is_terminal())?;
let document = read_credential_document(&mut io::stdin().lock())?;
let store = context.store()?;
let recorded = context.recorded_start_mode(&store)?;
let start_mode = StartMode::from(args.start_at);
record_start_mode(context, &store, recorded, start_mode)?;
let secrets = context.secret_store(start_mode)?;
store_received_credential(secrets.as_ref(), &document, out)
}
fn refuse_a_terminal(stdin_is_a_terminal: bool) -> Result<(), CliError> {
if !stdin_is_a_terminal {
return Ok(());
}
Err(CliError::with_remedy(
Failure::InvalidArgument,
"`auth receive` is a bridge between two processes and takes its input from a pipe. \
It refuses a terminal so that nobody is ever invited to type or paste a credential \
at a prompt, where it would survive in this shell's history and on this screen.",
"runner-manager auth login (the way a person authenticates this host)",
))
}
fn read_credential_document(reader: &mut dyn Read) -> Result<SecretString, CliError> {
let refused = |detail: &str| {
CliError::with_remedy(
Failure::InvalidArgument,
format!("the credential document on stdin was refused: {detail}. Nothing was stored."),
"runner-manager auth login (the way a person authenticates this host)",
)
};
let mut bytes = Vec::with_capacity(RECEIVED_DOCUMENT_LIMIT + 1);
reader
.take(RECEIVED_DOCUMENT_LIMIT as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|source| {
refused(&format!("stdin could not be read ({source})"))
})?;
let length = bytes.len();
if length > RECEIVED_DOCUMENT_LIMIT {
bytes.zeroize();
return Err(refused(&format!(
"it is larger than the {RECEIVED_DOCUMENT_LIMIT}-byte ceiling this endpoint accepts"
)));
}
if length == 0 {
return Err(refused("it was empty"));
}
let document = match std::str::from_utf8(&bytes) {
Ok(text) => SecretString::from(text.to_owned()),
Err(_) => {
bytes.zeroize();
return Err(refused(&format!(
"the {length} bytes there are not valid UTF-8"
)));
}
};
bytes.zeroize();
validate_credential_envelope(&document).map_err(|detail| refused(&detail))?;
Ok(document)
}
fn validate_credential_envelope(document: &SecretString) -> Result<(), String> {
const NOT_THE_DOCUMENT: &str = "it is not the credential document this version writes (a \
JSON object carrying an `access_token` string)";
let mut value = serde_json::from_str::<serde_json::Value>(document.expose_secret())
.map_err(|_| NOT_THE_DOCUMENT.to_string())?;
let outcome = match value.get("access_token") {
Some(serde_json::Value::String(token)) if !token.trim().is_empty() => {
validate_envelope_remainder(&value)
}
Some(serde_json::Value::String(_)) => Err("its access token is empty".to_string()),
Some(_) => Err("its access token is not a string".to_string()),
None => Err(NOT_THE_DOCUMENT.to_string()),
};
scrub_every_string(&mut value);
outcome
}
fn validate_envelope_remainder(value: &serde_json::Value) -> Result<(), String> {
match value.get("refresh_token") {
None | Some(serde_json::Value::Null | serde_json::Value::String(_)) => {}
Some(_) => return Err("its refresh token is not a string".to_string()),
}
for field in ["access_expires_at", "refresh_expires_at"] {
let readable = match value.get(field) {
None | Some(serde_json::Value::Null) => true,
Some(serde_json::Value::String(instant)) => {
DateTime::parse_from_rfc3339(instant).is_ok()
}
Some(_) => false,
};
if !readable {
return Err(format!("its `{field}` is not an RFC 3339 instant"));
}
}
Ok(())
}
fn scrub_every_string(value: &mut serde_json::Value) {
match value {
serde_json::Value::String(text) => text.zeroize(),
serde_json::Value::Array(items) => items.iter_mut().for_each(scrub_every_string),
serde_json::Value::Object(fields) => {
fields.values_mut().for_each(scrub_every_string);
}
_ => {}
}
}
fn store_received_credential(
secrets: &dyn SecretStore,
document: &SecretString,
out: &mut dyn Write,
) -> Result<(), CliError> {
let failed = write_failed("this credential handoff");
let token = UserAccessToken::from_stored_document(document);
let metadata = BrokeredCredential::of(&token);
secrets
.store(&token.to_stored_document())
.map_err(|source| secret_store_failure(&source))?;
writeln!(
out,
"Stored a credential in the {}-scoped store ({}).",
secrets.scope(),
secrets.location()
)
.map_err(failed)?;
writeln!(out, "{metadata}").map_err(failed)?;
Ok(())
}
#[derive(Debug)]
pub enum CredentialState {
NotAuthenticated,
Authenticated(Box<InstallationDiscovery>),
Revoked,
LockedOut { retry_after_secs: u64 },
Unreachable { detail: String },
}
impl CredentialState {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::NotAuthenticated => "not_authenticated",
Self::Authenticated(_) => "authenticated",
Self::Revoked => "revoked",
Self::LockedOut { .. } => "locked_out",
Self::Unreachable { .. } => "unreachable",
}
}
#[must_use]
pub const fn failure(&self) -> Option<Failure> {
match self {
Self::Authenticated(_) => None,
Self::NotAuthenticated => Some(Failure::NotAuthenticated),
Self::Revoked => Some(Failure::AuthenticationFailed),
Self::LockedOut { .. } => Some(Failure::AuthenticationLockout),
Self::Unreachable { .. } => Some(Failure::GithubUnavailable),
}
}
#[must_use]
pub const fn remedy(&self) -> &'static str {
match self {
Self::NotAuthenticated | Self::Revoked => "runner-manager auth login",
Self::LockedOut { .. } => {
"wait for the lockout to elapse, then runner-manager auth status"
}
Self::Unreachable { .. } => {
"check this host's network, then runner-manager auth status"
}
Self::Authenticated(_) => "runner-manager auth status",
}
}
}
pub fn credential_state(
context: &Context,
secrets: &dyn SecretStore,
) -> Result<CredentialState, CliError> {
let Some(secret) = secrets
.load()
.map_err(|source| secret_store_failure(&source))?
else {
return Ok(CredentialState::NotAuthenticated);
};
credential_state_of(context, secret)
}
pub fn credential_state_of(
context: &Context,
secret: SecretString,
) -> Result<CredentialState, CliError> {
let app = context.app_registration()?;
let client = AuthenticatedClient::new(
context.endpoints().clone(),
UserAccessToken::from_stored(secret),
context.clock(),
)
.map_err(|source| github_failure(&source))?;
let runtime = super::runtime()?;
match runtime.block_on(client.discover_installations(&app)) {
Ok(discovery) => Ok(CredentialState::Authenticated(Box::new(discovery))),
Err(GithubError::AuthenticationFailed) => Ok(CredentialState::Revoked),
Err(GithubError::AuthenticationLockout { retry_after }) => Ok(CredentialState::LockedOut {
retry_after_secs: retry_after.as_secs(),
}),
Err(source @ GithubError::Transport(_)) => Ok(CredentialState::Unreachable {
detail: source.to_string(),
}),
Err(source) => Err(github_failure(&source)),
}
}
pub fn status(
context: &Context,
args: &AuthStatusArgs,
styling: Styling,
out: &mut dyn Write,
) -> Result<(), CliError> {
let failed = write_failed("this credential's status");
if args.permissions {
write_permissions(out).map_err(failed)?;
writeln!(out).map_err(failed)?;
}
let store = context.store()?;
let start_mode = context.recorded_start_mode(&store)?;
let secrets = context.secret_store(start_mode)?;
let state = credential_state(context, secrets.as_ref())?;
writeln!(out, "Credential: {}", state.as_str()).map_err(failed)?;
writeln!(out, "Store: {}", secrets.location()).map_err(failed)?;
write_state_explanation(out, styling, &state, args.list).map_err(failed)?;
match state.failure() {
None => Ok(()),
Some(class) => Err(CliError::with_remedy(
class,
format!("the stored credential is {}", state.as_str()),
state.remedy(),
)),
}
}
fn write_state_explanation(
out: &mut dyn Write,
styling: Styling,
state: &CredentialState,
list: bool,
) -> io::Result<()> {
match state {
CredentialState::NotAuthenticated => {
writeln!(out)?;
writeln!(
out,
"There is no GitHub credential on this host. Nothing has been revoked and"
)?;
writeln!(
out,
"nothing is broken -- this is what a machine looks like before `auth login`."
)?;
}
CredentialState::Authenticated(discovery) => {
write_discovery(out, styling, discovery, false, list)?;
}
CredentialState::Revoked => {
writeln!(out)?;
writeln!(
out,
"GitHub no longer accepts the stored credential. That happens when the App is"
)?;
writeln!(
out,
"uninstalled or its authorization is revoked, and it is not something this host"
)?;
writeln!(out, "can undo: obtain a fresh token.")?;
}
CredentialState::LockedOut { retry_after_secs } => {
writeln!(out)?;
writeln!(
out,
"GitHub has temporarily locked out authentication for this credential. There is"
)?;
writeln!(
out,
"nothing wrong with the token itself, and signing in again will not help -- it"
)?;
writeln!(
out,
"extends the lockout. Wait about {retry_after_secs} seconds and ask again."
)?;
}
CredentialState::Unreachable { detail } => {
writeln!(out)?;
writeln!(
out,
"GitHub could not be reached, so nothing was learned about the stored"
)?;
writeln!(out, "credential: it may be perfectly good. {detail}")?;
}
}
Ok(())
}
fn write_discovery(
out: &mut dyn Write,
styling: Styling,
discovery: &InstallationDiscovery,
onboarding: bool,
list: bool,
) -> io::Result<()> {
match discovery {
InstallationDiscovery::NotInstalled { install_url } => {
if onboarding {
write_action_three(out, styling, install_url)?;
} else {
writeln!(out)?;
writeln!(
out,
"The App is installed nowhere this credential can reach, so it sees no"
)?;
writeln!(out, "repositories and no organizations.")?;
writeln!(out)?;
writeln!(out, " Install it: {install_url}")?;
}
}
InstallationDiscovery::Indeterminate { skipped } => {
writeln!(out)?;
writeln!(
out,
"GitHub reported {skipped} installation(s) this tool could not describe, and no"
)?;
writeln!(
out,
"others. Whether the App is installed cannot be determined from here, so no"
)?;
writeln!(
out,
"installation URL is offered: it might be the wrong advice."
)?;
}
InstallationDiscovery::Installed(targets) => {
let repositories = targets.repositories();
let organizations = targets.organizations();
writeln!(out)?;
writeln!(
out,
"Reaches {} repositor{} and {} organization{}:",
repositories.len(),
if repositories.len() == 1 { "y" } else { "ies" },
organizations.len(),
if organizations.len() == 1 { "" } else { "s" },
)?;
for installation in targets.installations() {
write_installation(out, installation, list)?;
}
if targets.skipped() > 0 {
writeln!(
out,
" NOTE: {} further installation(s) could not be described, so this list is \
incomplete rather than merely short.",
targets.skipped()
)?;
}
let over_broad = targets.over_broad();
if !over_broad.is_empty() {
writeln!(
out,
" warning: {} installation(s) reach ALL repositories on the account, \
including ones created later.",
over_broad.len()
)?;
}
if !list && !repositories.is_empty() {
writeln!(out, " Add --list to name every repository.")?;
}
}
}
Ok(())
}
fn write_installation(
out: &mut dyn Write,
installation: &Installation,
list: bool,
) -> io::Result<()> {
let selection = match installation.repository_selection {
RepositorySelection::All => "ALL repositories",
RepositorySelection::Selected => "selected repositories",
};
writeln!(
out,
" {} ({}, installation {}, {selection}, {} reachable)",
installation.account,
installation.account.kind(),
installation.id,
installation.repositories.len(),
)?;
if list {
for repository in &installation.repositories {
writeln!(out, " {repository}")?;
}
}
Ok(())
}
pub const REVOCATION_HEADLINE: &str = "Authoritative revocation is uninstalling the App at GitHub.";
pub fn write_revocation_notice(out: &mut dyn Write) -> io::Result<()> {
writeln!(out)?;
writeln!(
out,
"This host can no longer talk to GitHub, and that is the whole of what just happened."
)?;
writeln!(
out,
"The token itself is still valid at GitHub, and any other host holding a copy still"
)?;
writeln!(out, "works.")?;
writeln!(out)?;
writeln!(out, "{REVOCATION_HEADLINE} Revoking its authorization does")?;
writeln!(out, "the same. Either is done in your GitHub settings:")?;
writeln!(out)?;
writeln!(
out,
" https://github.com/settings/installations (your own account)"
)?;
writeln!(
out,
" an organization's Settings -> GitHub Apps (an organization)"
)?;
writeln!(out)?;
writeln!(
out,
"Neither needs this project's cooperation, and neither is something `auth logout`"
)?;
writeln!(out, "can do on your behalf.")?;
Ok(())
}
pub fn logout(context: &Context, out: &mut dyn Write) -> Result<(), CliError> {
let failed = write_failed("this sign-out");
let store = context.store()?;
let start_mode = context.recorded_start_mode(&store)?;
let secrets = context.secret_store(start_mode)?;
let removal = secrets
.delete()
.map_err(|source| secret_store_failure(&source))?;
match removal {
Removal::Removed => writeln!(
out,
"Removed the stored credential from the {}.",
secrets.location()
),
Removal::AlreadyAbsent => writeln!(
out,
"There was no stored credential in the {}. Nothing to remove.",
secrets.location()
),
}
.map_err(failed)?;
write_revocation_notice(out).map_err(failed)?;
Ok(())
}
fn device_flow_failure(source: &DeviceFlowError) -> CliError {
match source {
DeviceFlowError::AccessDenied => CliError::with_remedy(
Failure::AuthenticationDeclined,
"the login was declined on GitHub. Nothing was stored and nothing changed.",
"runner-manager auth login (only if the refusal was a mistake)",
),
DeviceFlowError::Expired | DeviceFlowError::IncorrectDeviceCode => CliError::with_remedy(
Failure::AuthenticationExpired,
format!("{source}. Codes are single-use and short-lived."),
"runner-manager auth login",
),
DeviceFlowError::AppMisconfigured { .. } => CliError::new(
Failure::AppMisconfigured,
format!(
"{source}. This is a defect in the published App, not in this host's setup, \
so {NO_OPERATOR_REMEDY}. Please report it against the project."
),
),
DeviceFlowError::UntrustedVerificationUri { origin } => CliError::new(
Failure::UnusableResponse,
format!(
"refusing to continue: the device-flow response pointed the approval page at \
{origin:?}, which is not GitHub. Your code has not been shown and nothing has \
been stored. Treat this as an interception attempt on this network -- \
{NO_OPERATOR_REMEDY}, and signing in again from here would present the code \
to the same party."
),
),
DeviceFlowError::Transport(_) => CliError::with_remedy(
Failure::GithubUnavailable,
format!("{source}. Nothing was stored."),
"check this host's network, then runner-manager auth login",
),
DeviceFlowError::Status { .. } | DeviceFlowError::Unexpected { .. } => {
CliError::with_remedy(
Failure::GithubRefused,
format!("{source}. Nothing was stored."),
"runner-manager auth login",
)
}
DeviceFlowError::Decode { .. } | DeviceFlowError::Malformed { .. } => {
CliError::with_remedy(
Failure::UnusableResponse,
format!("{source}. Nothing was stored."),
"runner-manager auth login",
)
}
DeviceFlowError::Config(_) => CliError::new(
Failure::AppNotPublished,
format!(
"the App registration this build carries is unusable: {source}. That is a \
property of the build rather than of this host, so {NO_OPERATOR_REMEDY}."
),
),
}
}
fn github_failure(source: &GithubError) -> CliError {
match source {
GithubError::AuthenticationFailed => CliError::with_remedy(
Failure::AuthenticationFailed,
source.to_string(),
"runner-manager auth login",
),
GithubError::AuthenticationLockout { .. } => CliError::with_remedy(
Failure::AuthenticationLockout,
format!("{source}. The credential itself is fine."),
"wait for the lockout to elapse, then runner-manager auth status",
),
GithubError::Transport(_) => CliError::with_remedy(
Failure::GithubUnavailable,
source.to_string(),
"check this host's network, then runner-manager auth status",
),
GithubError::Forbidden { .. } | GithubError::Status { .. } => CliError::with_remedy(
Failure::GithubRefused,
source.to_string(),
"runner-manager auth status",
),
GithubError::Decode { .. } | GithubError::Malformed { .. } => CliError::with_remedy(
Failure::UnusableResponse,
source.to_string(),
"runner-manager auth status",
),
GithubError::Config(_) => CliError::new(
Failure::AppNotPublished,
format!("{source}. That is a property of this build, so {NO_OPERATOR_REMEDY}."),
),
}
}
fn secret_store_failure(source: &SecretStoreError) -> CliError {
match source {
SecretStoreError::Corrupt { .. } => CliError::with_remedy(
Failure::SecretStore,
source.to_string(),
"runner-manager auth logout, then runner-manager auth login",
),
SecretStoreError::Resolve { .. }
| SecretStoreError::Store { .. }
| SecretStoreError::Load { .. }
| SecretStoreError::Delete { .. }
| SecretStoreError::Inspect { .. } => CliError::with_remedy(
Failure::SecretStore,
source.to_string(),
"runner-manager host show (reports where the store is and what protects it)",
),
}
}
#[allow(
dead_code,
reason = "the caller is `b2-wsl-cli-orchestration`; `b1` owns this seam"
)]
fn secret_sink_failure(source: &SecretSinkError) -> CliError {
match source {
SecretSinkError::Undeliverable { .. } => CliError::with_remedy(
Failure::SecretStore,
format!(
"{source}. No credential was stored on this host either: this handoff writes \
nowhere but the receiving store."
),
"check that the receiving host is running and reachable, then run the \
provisioning command again",
),
SecretSinkError::Refused { .. } => CliError::with_remedy(
Failure::SecretStore,
format!(
"{source}. Nothing was stored on this host, so the credential just issued is \
gone and the next attempt issues a new one."
),
"fix the receiving host's secret store, then run the provisioning command again",
),
}
}
#[cfg(test)]
mod tests {
use super::*;
const ACTION_PREFIX: &str = "Action ";
#[derive(Debug, Clone, PartialEq, Eq)]
struct OnboardingAction {
index: usize,
total: usize,
text: String,
}
#[must_use]
fn onboarding_actions(transcript: &str) -> Vec<OnboardingAction> {
let mut found = Vec::new();
for line in transcript.lines() {
let Some(rest) = line.trim_start().strip_prefix(ACTION_PREFIX) else {
continue;
};
let Some((counts, text)) = rest.split_once(':') else {
continue;
};
let Some((index, total)) = counts.trim().split_once(" of ") else {
continue;
};
let total = total.split_whitespace().next().unwrap_or_default();
let (Ok(index), Ok(total)) = (index.trim().parse::<usize>(), total.parse::<usize>())
else {
continue;
};
found.push(OnboardingAction {
index,
total,
text: text.trim().to_string(),
});
}
found
}
fn check_onboarding_budget(actions: &[OnboardingAction]) -> Result<(), String> {
if actions.is_empty() {
return Err(
"the transcript counted no onboarding actions at all, so any count read \
from it would be vacuous"
.to_string(),
);
}
for action in actions {
if action.total != ONBOARDING_ACTIONS {
return Err(format!(
"action {} announces a budget of {}, but D3 allows {ONBOARDING_ACTIONS}",
action.index, action.total
));
}
}
if actions.len() > ONBOARDING_ACTIONS {
return Err(format!(
"the transcript asks the operator for {} actions, over D3's budget of \
{ONBOARDING_ACTIONS}",
actions.len()
));
}
for (position, action) in actions.iter().enumerate() {
let expected = position + 1;
if action.index != expected {
return Err(format!(
"action {} appears in position {expected}: the numbering must be dense and \
ascending, or a skipped step reads as a smaller budget than it is",
action.index
));
}
}
Ok(())
}
fn text(render: impl FnOnce(&mut dyn Write) -> io::Result<()>) -> String {
let mut buffer = Vec::new();
render(&mut buffer).expect("writing to a Vec cannot fail");
String::from_utf8(buffer).expect("the copy is ASCII")
}
fn transcript() -> String {
text(|out| {
write_action_one(out, Styling::plain())?;
write_action_two(
out,
Styling::plain(),
&"https://github.com/login/device",
"WDJB-MJHT",
Duration::from_secs(900),
false,
)?;
write_action_three(
out,
Styling::plain(),
&"https://github.com/apps/example/installations/new",
)
})
}
#[test]
fn the_login_screen_carries_no_permission_table() {
let transcript = transcript();
let permissions = text(write_permissions);
let mut needles = vec![CRITICAL_PERMISSION, "DELETING", "monitor-only"];
for (permission, _, _) in PERMISSIONS {
needles.push(permission);
}
for needle in needles {
assert!(
permissions.contains(needle),
"`{needle}` must be somewhere a reader can reach it, or the assertion below passes because nothing renders it at all rather than because `login` stopped rendering it"
);
assert!(
!transcript.contains(needle),
"`auth login` must not print `{needle}`. The table is identical on every run, and it sat above the one code the operator came for:
{transcript}"
);
}
}
#[test]
fn the_permission_report_states_what_the_administration_grant_actually_permits() {
let permissions = text(write_permissions);
assert!(
permissions.contains(CRITICAL_PERMISSION),
"the exact grant must be named"
);
for consequence in ["DELETING", "RENAMING", "TRANSFERRING"] {
assert!(
permissions.contains(consequence),
"{consequence} is one of the three consequences `07-security.md` names for `{CRITICAL_PERMISSION}`; a table of permission names without them is the disclosure GitHub's own consent screen already gives"
);
}
assert!(
permissions.contains("monitor-only"),
"D21's accepted cost is that this binds a dashboard-only user too, who is the user least likely to expect a write grant"
);
assert!(
permissions.contains("Organization scope is"),
"`07-security.md` requires the safer scope to be recommended where both work"
);
assert!(
permissions.contains("Revoke"),
"and the reader must be told the grant is theirs to withdraw"
);
for (permission, level, _) in PERMISSIONS {
assert!(
permissions.contains(permission),
"the permission table must list {permission}"
);
assert!(permissions.contains(level));
}
}
#[test]
fn the_permission_report_contains_the_consequence_sentences_verbatim() {
let consequences = text(write_grant_consequences);
let permissions = text(write_permissions);
for sentence in consequences.lines().filter(|line| !line.trim().is_empty()) {
assert!(
permissions.contains(sentence),
"`write_permissions` must carry `write_grant_consequences` verbatim, or the monitor-only warning and the full report are two disclosures that can disagree. Missing:
{sentence}"
);
}
}
#[test]
fn a_clean_machine_reaches_an_authenticated_tool_in_three_actions() {
let transcript = transcript();
let actions = onboarding_actions(&transcript);
assert_eq!(
actions.len(),
ONBOARDING_ACTIONS,
"D3's release gate is three: one command, one code entry, one repository \
selection. Found: {actions:#?}"
);
check_onboarding_budget(&actions).expect("the transcript must honour D3's budget");
assert!(
actions[0].text.contains("runner-manager auth login"),
"the first action is the one command D3 budgets for: {:?}",
actions[0].text
);
assert!(
actions[1].text.contains("github.com/login/device"),
"the second action is the code entry, on GitHub's own page: {:?}",
actions[1].text
);
assert!(
actions[2].text.contains("installations/new"),
"the third action is the repository selection: {:?}",
actions[2].text
);
}
#[test]
fn the_budget_check_rejects_a_fourth_action() {
let mut transcript = transcript();
transcript.push_str("Action 4 of 3: run `runner-manager auth confirm`.\n");
let actions = onboarding_actions(&transcript);
assert_eq!(actions.len(), 4, "the parser must see the extra action");
let rejected = check_onboarding_budget(&actions)
.expect_err("four actions must not pass a budget of three");
assert!(
rejected.contains("over D3's budget"),
"the rejection must name the budget: {rejected}"
);
}
#[test]
fn the_budget_check_rejects_a_widened_budget() {
let widened = "Action 1 of 4: a\nAction 2 of 4: b\nAction 3 of 4: c\nAction 4 of 4: d\n";
let actions = onboarding_actions(widened);
assert_eq!(actions.len(), 4);
let rejected =
check_onboarding_budget(&actions).expect_err("a budget of four is not D3's budget");
assert!(rejected.contains("announces a budget of 4"), "{rejected}");
}
#[test]
fn the_budget_check_rejects_a_transcript_it_could_not_parse() {
let rejected = check_onboarding_budget(&[])
.expect_err("counting nothing must not read as honouring the budget");
assert!(rejected.contains("vacuous"), "{rejected}");
}
#[test]
fn the_action_parser_ignores_lines_that_only_look_like_actions() {
let noise = "Actions are counted.\nAction two of three: no.\nAction 1 of 3: yes.\n";
let actions = onboarding_actions(noise);
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].index, 1);
assert_eq!(actions[0].total, 3);
assert_eq!(actions[0].text, "yes.");
}
#[test]
fn the_prompt_shows_the_user_code_and_names_only_githubs_own_page() {
let prompt = text(|out| {
write_action_two(
out,
Styling::plain(),
&"https://github.com/login/device",
"WDJB-MJHT",
Duration::from_secs(900),
false,
)
});
assert!(prompt.contains("WDJB-MJHT"));
assert!(prompt.contains("https://github.com/login/device"));
assert!(
prompt.contains("only on that page"),
"`07-security.md`'s phishing control requires the copy to say the code is only \
ever entered on GitHub's own domain"
);
assert_eq!(
prompt.matches("http").count(),
1,
"the prompt must offer exactly one URL. A second one is a second place a user \
might type the code: {prompt}"
);
}
#[test]
fn every_credential_state_reports_itself_distinctly() {
let states = [
CredentialState::NotAuthenticated,
CredentialState::Revoked,
CredentialState::LockedOut {
retry_after_secs: 60,
},
CredentialState::Unreachable {
detail: "GitHub was unreachable".to_string(),
},
];
let mut codes = std::collections::BTreeSet::new();
let mut names = std::collections::BTreeSet::new();
for state in &states {
let class = state
.failure()
.expect("only `authenticated` is a success state");
assert!(
codes.insert(class.code()),
"{} shares an exit code with another state, so a script cannot tell them \
apart -- and `03-control-flows.md` flow 4.3 requires the lockout to be \
reported distinctly from `authentication_failed`",
state.as_str()
);
assert!(names.insert(state.as_str()));
}
assert_eq!(codes.len(), states.len());
assert_eq!(names.len(), states.len());
}
#[test]
fn each_state_explains_itself_in_its_own_words() {
let states = [
CredentialState::NotAuthenticated,
CredentialState::Revoked,
CredentialState::LockedOut {
retry_after_secs: 60,
},
CredentialState::Unreachable {
detail: "GitHub was unreachable".to_string(),
},
];
let mut seen = std::collections::BTreeSet::new();
for state in &states {
let rendered = text(|out| write_state_explanation(out, Styling::plain(), state, false));
assert!(
seen.insert(rendered.clone()),
"{} renders the same explanation as an earlier state",
state.as_str()
);
}
let unreachable = text(|out| {
write_state_explanation(
out,
Styling::plain(),
&CredentialState::Unreachable {
detail: "GitHub was unreachable".to_string(),
},
false,
)
});
assert!(
unreachable.contains("may be perfectly good"),
"an offline host must not be told its credential is bad: {unreachable}"
);
}
#[test]
fn a_lockout_is_never_remedied_by_signing_in_again() {
let state = CredentialState::LockedOut {
retry_after_secs: 90,
};
let remedy = state.remedy();
assert!(
!remedy.contains("auth login"),
"`03-control-flows.md` flow 4.3: a lockout is not a permissions change and not a \
bad credential. Telling the operator to re-authenticate during one extends it. \
Got: {remedy}"
);
assert!(remedy.contains("wait"), "got: {remedy}");
let explanation = text(|out| write_state_explanation(out, Styling::plain(), &state, false));
assert!(
explanation.contains("nothing wrong with the token itself"),
"got: {explanation}"
);
}
#[test]
fn a_declined_login_and_an_expired_code_are_different_answers() {
let declined = device_flow_failure(&DeviceFlowError::AccessDenied);
assert_eq!(declined.class(), Failure::AuthenticationDeclined);
let expired = device_flow_failure(&DeviceFlowError::Expired);
assert_eq!(expired.class(), Failure::AuthenticationExpired);
assert_ne!(
declined.class().code(),
expired.class().code(),
"`c2` documents these as different answers -- retrying a refusal re-prompts \
somebody who already said no, while an expired code simply needs a new login -- \
so a script has to be able to tell them apart"
);
}
#[test]
fn an_untrusted_verification_page_is_reported_as_an_interception() {
let error = device_flow_failure(&DeviceFlowError::UntrustedVerificationUri {
origin: "https://github.example.com".to_string(),
});
assert_eq!(error.class(), Failure::UnusableResponse);
assert!(error.message().contains("interception"), "{error}");
assert!(
error.message().contains("not been shown"),
"the message must say the code was withheld: {error}"
);
}
fn assert_says_what_to_do_next(error: &CliError, what: &str) {
assert_eq!(
error.remedy().is_some(),
!error.message().contains(NO_OPERATOR_REMEDY),
"{what}: `{}` must either name the command that fixes it or say plainly, in \
the words of NO_OPERATOR_REMEDY, that none does -- and never both. remedy: \
{:?}",
error.message(),
error.remedy()
);
assert!(
!error.message().is_empty() && error.message().len() < 500,
"{what}: one screenful, not a stack trace: {}",
error.message()
);
}
fn assert_both_halves_were_seen(errors: &[CliError], what: &str) {
let with_remedy = errors.iter().filter(|e| e.remedy().is_some()).count();
let without = errors.len() - with_remedy;
assert!(
with_remedy > 0 && without > 0,
"{what}: both halves of the rule must be exercised: {with_remedy} with a \
remedy, {without} without"
);
}
#[test]
fn every_device_flow_failure_says_what_to_do_next() {
let cases = [
DeviceFlowError::AccessDenied,
DeviceFlowError::Expired,
DeviceFlowError::IncorrectDeviceCode,
DeviceFlowError::AppMisconfigured {
code: "device_flow_disabled".to_string(),
},
DeviceFlowError::Unexpected {
code: "surprise".to_string(),
},
DeviceFlowError::UntrustedVerificationUri {
origin: "https://not-github.example".to_string(),
},
DeviceFlowError::Status {
status: 502,
stage: "device code request",
},
DeviceFlowError::Malformed {
what: "a verification URL",
value: "::".to_string(),
},
];
let errors: Vec<CliError> = cases.iter().map(device_flow_failure).collect();
for error in &errors {
assert_says_what_to_do_next(error, "device_flow_failure");
}
assert_both_halves_were_seen(&errors, "device_flow_failure");
}
#[test]
fn every_github_failure_says_what_to_do_next() {
use runner_manager_github::{ConfigError, HeaderMap};
let decode_error =
serde_json::from_str::<u32>("not a number").expect_err("a deliberate decode failure");
let cases = [
GithubError::AuthenticationFailed,
GithubError::AuthenticationLockout {
retry_after: Duration::from_secs(60),
},
GithubError::Forbidden {
method: "GET".to_string(),
path: "/user/installations".to_string(),
message: Some("Resource not accessible by integration".to_string()),
headers: Box::new(HeaderMap::new()),
},
GithubError::Status {
status: 500,
method: "GET".to_string(),
path: "/user/installations".to_string(),
message: None,
headers: Box::new(HeaderMap::new()),
},
GithubError::Decode {
what: "an installations",
expected: "a page",
source: decode_error,
},
GithubError::Malformed {
what: "a repository full_name",
value: "not/a/slug".to_string(),
},
GithubError::Config(ConfigError::Empty { what: "client_id" }),
];
let errors: Vec<CliError> = cases.iter().map(github_failure).collect();
for error in &errors {
assert_says_what_to_do_next(error, "github_failure");
}
assert_both_halves_were_seen(&errors, "github_failure");
}
#[test]
fn a_stock_build_carries_the_published_app_registration() {
let temporary = tempfile::tempdir().expect("a temporary directory");
let mut discarded = Vec::new();
let context = Context::resolve(Some(temporary.path()), &mut discarded)
.expect("a context rooted at a temporary directory");
if std::env::var(crate::cli::CLIENT_ID_VARIABLE).is_ok()
|| std::env::var(crate::cli::APP_SLUG_VARIABLE).is_ok()
{
eprintln!(
"SKIPPED: {} or {} is set, so this process is not a stock build",
crate::cli::CLIENT_ID_VARIABLE,
crate::cli::APP_SLUG_VARIABLE
);
return;
}
let registration = context
.app_registration()
.expect("a stock build carries the published App registration");
assert_eq!(registration.client_id(), crate::cli::PUBLISHED_CLIENT_ID);
assert_eq!(registration.slug(), crate::cli::PUBLISHED_APP_SLUG);
}
#[test]
fn a_missing_app_registration_says_that_no_command_fixes_it() {
let error = CliError::new(
Failure::AppNotPublished,
format!(
"this build carries no published GitHub App registration, so there is \
nothing to sign in to. {NO_OPERATOR_REMEDY}."
),
);
assert_eq!(error.class(), Failure::AppNotPublished);
assert_says_what_to_do_next(&error, "Context::app_registration");
assert!(
error.remedy().is_none(),
"there is no operator command that publishes a GitHub App: {error}"
);
}
use std::collections::HashMap;
use std::io::{BufRead as _, BufReader};
use std::net::{Shutdown, TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use runner_manager_github::Endpoints;
use runner_manager_platform::process::HandoffError;
use runner_manager_platform::secrets::{PlatformSecretStore, Protection, SecretScope};
fn windows_access_canary() -> String {
format!("{}{}", "ghu_", "b1WindowsAccessNeverTransferred")
}
fn windows_refresh_canary() -> String {
format!("{}{}", "ghr_", "b1WindowsRefreshNeverTransferrd")
}
fn wsl_access_canary() -> String {
format!("{}{}", "ghu_", "b1WslAccessIssuedIndependently0")
}
fn wsl_refresh_canary() -> String {
format!("{}{}", "ghr_", "b1WslRefreshIssuedIndependently")
}
fn every_canary() -> Vec<(&'static str, String)> {
vec![
("the Windows access token", windows_access_canary()),
("the Windows refresh token", windows_refresh_canary()),
("the WSL access token", wsl_access_canary()),
("the WSL refresh token", wsl_refresh_canary()),
]
}
fn document(access: &str, refresh: &str) -> String {
format!(
r#"{{"access_token":"{access}","refresh_token":"{refresh}",
"access_expires_at":"2026-09-06T20:00:00Z",
"refresh_expires_at":"2027-03-05T12:00:00Z"}}"#
)
}
fn files_under(root: &Path) -> Vec<(String, String)> {
let mut found = Vec::new();
let mut pending = vec![root.to_path_buf()];
while let Some(directory) = pending.pop() {
let Ok(entries) = std::fs::read_dir(&directory) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
pending.push(path);
} else if let Ok(bytes) = std::fs::read(&path) {
found.push((
path.display().to_string(),
String::from_utf8_lossy(&bytes).into_owned(),
));
}
}
}
found
}
fn no_canary_escaped(transcript: &str, root: &Path, allowed: &dyn Fn(&str) -> bool) {
let files: Vec<(String, String)> = files_under(root)
.into_iter()
.filter(|(path, _)| !allowed(path))
.collect();
let environment: Vec<(String, String)> = std::env::vars().collect();
let argv: Vec<String> = std::env::args().collect();
let mut found = Vec::new();
for (name, canary) in every_canary() {
if transcript.contains(&canary) {
found.push(format!("{name} appears in the command's own output"));
}
for (path, text) in &files {
if text.contains(&canary) {
found.push(format!("{name} appears in the file {path}"));
}
}
for (key, value) in &environment {
if value.contains(&canary) {
found.push(format!("{name} appears in the environment as {key}"));
}
}
if argv.iter().any(|argument| argument.contains(&canary)) {
found.push(format!("{name} appears in this process's argv"));
}
}
assert!(
found.is_empty(),
"`03-security-and-lifecycle.md` guarantee 3: the credential document is absent \
from argv, environment, logs, errors and temporary files. Found:\n {}",
found.join("\n ")
);
}
fn no_credential_was_staged(root: &Path) {
for scope in [SecretScope::Machine, SecretScope::User] {
let store =
PlatformSecretStore::rooted_at(scope, root).expect("a rooted store resolves");
let held = store.load().unwrap_or(None);
assert!(
held.is_none(),
"`03-security-and-lifecycle.md` guarantee 5: a failed handoff leaves no \
credential copy, and the {scope}-scoped store holds one"
);
}
}
struct RecordingSink {
delivered: Option<String>,
calls: usize,
refuse: Option<SecretSinkError>,
}
impl RecordingSink {
fn accepting() -> Self {
Self {
delivered: None,
calls: 0,
refuse: None,
}
}
fn refusing(error: SecretSinkError) -> Self {
Self {
delivered: None,
calls: 0,
refuse: Some(error),
}
}
}
impl SecretSink for RecordingSink {
fn send(&mut self, document: &SecretString) -> Result<(), SecretSinkError> {
self.calls += 1;
if let Some(error) = self.refuse.take() {
return Err(error);
}
self.delivered = Some(document.expose_secret().to_string());
Ok(())
}
}
#[derive(Debug)]
struct UnreadableStore {
held: String,
loads: AtomicUsize,
writes: AtomicUsize,
deletes: AtomicUsize,
}
impl UnreadableStore {
fn holding(document: &str) -> Self {
Self {
held: document.to_string(),
loads: AtomicUsize::new(0),
writes: AtomicUsize::new(0),
deletes: AtomicUsize::new(0),
}
}
fn loads(&self) -> usize {
self.loads.load(Ordering::SeqCst)
}
fn writes(&self) -> usize {
self.writes.load(Ordering::SeqCst)
}
fn deletes(&self) -> usize {
self.deletes.load(Ordering::SeqCst)
}
fn held(&self) -> &str {
&self.held
}
fn refusal() -> std::io::Error {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"this store is not the credential broker's to touch",
)
}
}
impl SecretStore for UnreadableStore {
fn scope(&self) -> SecretScope {
SecretScope::Machine
}
fn location(&self) -> String {
"the machine-scoped store this host is already signed in to".to_string()
}
fn store(&self, _secret: &SecretString) -> Result<(), SecretStoreError> {
self.writes.fetch_add(1, Ordering::SeqCst);
Err(SecretStoreError::Store {
scope: self.scope(),
location: self.location(),
source: Self::refusal(),
})
}
fn load(&self) -> Result<Option<SecretString>, SecretStoreError> {
self.loads.fetch_add(1, Ordering::SeqCst);
Err(SecretStoreError::Load {
scope: self.scope(),
location: self.location(),
source: Self::refusal(),
})
}
fn delete(&self) -> Result<Removal, SecretStoreError> {
self.deletes.fetch_add(1, Ordering::SeqCst);
Err(SecretStoreError::Delete {
scope: self.scope(),
location: self.location(),
source: Self::refusal(),
})
}
fn protection(&self) -> Result<Protection, SecretStoreError> {
let guard = PathBuf::from("(a test double: there is no file behind this store)");
Err(SecretStoreError::Inspect {
scope: self.scope(),
guard: guard.clone(),
source: HandoffError::Inspect {
path: guard,
source: Self::refusal(),
},
})
}
}
struct FakeDeviceFlow {
base_url: String,
stop: Arc<AtomicBool>,
requests: Arc<AtomicUsize>,
worker: Option<std::thread::JoinHandle<()>>,
}
impl FakeDeviceFlow {
fn start(token_reply: &str) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("a loopback port is available");
let port = listener.local_addr().expect("a bound listener").port();
listener
.set_nonblocking(true)
.expect("the listener must be pollable");
let base_url = format!("http://127.0.0.1:{port}/");
let mut replies = HashMap::new();
replies.insert(
"/login/device/code".to_string(),
format!(
r#"{{"device_code":"b1-fixture-device-code","user_code":"WDJB-MJHT",
"verification_uri":"{base_url}login/device",
"expires_in":900,"interval":0}}"#
),
);
replies.insert(
"/login/oauth/access_token".to_string(),
token_reply.to_string(),
);
let replies = Arc::new(replies);
let stop = Arc::new(AtomicBool::new(false));
let requests = Arc::new(AtomicUsize::new(0));
let worker = {
let stop = Arc::clone(&stop);
let requests = Arc::clone(&requests);
std::thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => {
let replies = Arc::clone(&replies);
let requests = Arc::clone(&requests);
std::thread::spawn(move || {
answer(&stream, &replies, &requests);
});
}
Err(ref error) if error.kind() == io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(2));
}
Err(_) => break,
}
}
})
};
Self {
base_url,
stop,
requests,
worker: Some(worker),
}
}
fn approving() -> Self {
Self::start(&format!(
r#"{{"access_token":"{}","token_type":"bearer","scope":"",
"refresh_token":"{}","expires_in":28800,
"refresh_token_expires_in":15811200}}"#,
wsl_access_canary(),
wsl_refresh_canary()
))
}
fn endpoints(&self) -> Endpoints {
Endpoints::for_test_server(&self.base_url).expect("a loopback base is a valid URL")
}
fn requests_answered(&self) -> usize {
self.requests.load(Ordering::Relaxed)
}
}
impl Drop for FakeDeviceFlow {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
}
}
fn answer(stream: &TcpStream, replies: &HashMap<String, String>, requests: &AtomicUsize) {
let _ = stream.set_nonblocking(false);
let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(10)));
let mut reader = BufReader::new(stream);
let mut request_line = String::new();
if reader.read_line(&mut request_line).is_err() || request_line.trim().is_empty() {
return;
}
let path = request_line
.split_whitespace()
.nth(1)
.unwrap_or_default()
.split('?')
.next()
.unwrap_or_default()
.to_string();
let mut content_length = 0_usize;
loop {
let mut header = String::new();
if reader.read_line(&mut header).is_err() {
return;
}
if header.trim().is_empty() {
break;
}
if let Some((name, value)) = header.split_once(':')
&& name.eq_ignore_ascii_case("content-length")
{
content_length = value.trim().parse().unwrap_or(0);
}
}
let mut body = vec![0_u8; content_length];
if content_length > 0 && reader.read_exact(&mut body).is_err() {
return;
}
requests.fetch_add(1, Ordering::Relaxed);
let response = replies.get(&path).map_or_else(
|| {
"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"
.to_string()
},
|body| {
format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\
connection: close\r\n\r\n{body}",
body.len()
)
},
);
let mut sink = stream;
let _ = sink.write_all(response.as_bytes());
let _ = sink.flush();
let _ = stream.shutdown(Shutdown::Write);
}
fn context_against(root: &Path, github: &FakeDeviceFlow) -> Context {
Context::rooted_against(root, github.endpoints()).expect("a context rooted at a temp dir")
}
fn context_against_nothing(root: &Path) -> Context {
Context::rooted_against(
root,
Endpoints::for_test_server("http://127.0.0.1:1/").expect("a valid URL"),
)
.expect("a context rooted at a temp dir")
}
#[test]
fn receive_refuses_a_terminal_and_accepts_a_pipe() {
let refusal = refuse_a_terminal(true).expect_err("a terminal must be refused");
assert_eq!(refusal.class(), Failure::InvalidArgument);
assert!(
refusal.to_string().contains("pipe"),
"the refusal must say what it does take: {refusal}"
);
assert!(
refusal.remedy().is_some_and(|r| r.contains("auth login")),
"a person who typed this wanted `auth login`: {refusal:?}"
);
refuse_a_terminal(false).expect("a pipe is the input this endpoint is for");
}
#[test]
fn receive_refuses_an_empty_document() {
let error = read_credential_document(&mut &b""[..]).expect_err("empty is not a credential");
assert_eq!(error.class(), Failure::InvalidArgument);
assert!(error.to_string().contains("empty"), "got: {error}");
}
#[test]
fn receive_refuses_a_document_over_the_ceiling_and_accepts_one_at_it() {
let over = vec![b'x'; RECEIVED_DOCUMENT_LIMIT + 1];
let error =
read_credential_document(&mut over.as_slice()).expect_err("oversized is refused");
assert_eq!(error.class(), Failure::InvalidArgument);
assert!(
error
.to_string()
.contains(&RECEIVED_DOCUMENT_LIMIT.to_string()),
"the refusal must name the ceiling: {error}"
);
let mut at_the_ceiling = document(&wsl_access_canary(), &wsl_refresh_canary());
assert!(at_the_ceiling.len() < RECEIVED_DOCUMENT_LIMIT);
at_the_ceiling.push_str(&" ".repeat(RECEIVED_DOCUMENT_LIMIT - at_the_ceiling.len()));
assert_eq!(at_the_ceiling.len(), RECEIVED_DOCUMENT_LIMIT);
read_credential_document(&mut at_the_ceiling.as_bytes())
.expect("a document exactly at the ceiling is within it");
}
#[test]
fn receive_refuses_a_malformed_document() {
let bare_token = format!("{}{}", "ghu_", "looksLikeAToken");
let refused: Vec<(&str, &str)> = vec![
("prose", "not a credential at all"),
(
"an HTML error page",
"<html><body>502 Bad Gateway</body></html>",
),
("a bare token", bare_token.as_str()),
("an object with no access token", r#"{"refresh_token":"x"}"#),
("an empty access token", r#"{"access_token":""}"#),
("a blank access token", r#"{"access_token":" "}"#),
("a JSON array", r#"["access_token"]"#),
("a numeric access token", r#"{"access_token":1234}"#),
("a null access token", r#"{"access_token":null}"#),
("a JSON string", r#""just a string""#),
("a truncated document", r#"{"access_token":"gh"#),
(
"a document whose access expiry is not an instant",
r#"{"access_token":"ghu_x","access_expires_at":"tomorrow"}"#,
),
(
"a document whose refresh token is not a string",
r#"{"access_token":"ghu_x","refresh_token":1234}"#,
),
];
for (what, raw) in refused {
let error = read_credential_document(&mut raw.as_bytes())
.err()
.unwrap_or_else(|| panic!("{what} must be refused and was accepted"));
assert_eq!(error.class(), Failure::InvalidArgument, "{what}: {error}");
assert!(
error.to_string().contains("Nothing was stored"),
"{what} must say that nothing was stored: {error}"
);
}
}
#[test]
fn receive_refuses_bytes_that_are_not_text() {
let error = read_credential_document(&mut &[0xff_u8, 0xfe, 0xfd][..])
.expect_err("arbitrary bytes are not a credential document");
assert_eq!(error.class(), Failure::InvalidArgument);
assert!(error.to_string().contains("3 bytes"), "got: {error}");
}
#[test]
fn a_refusal_never_quotes_the_document_it_refused() {
let truncated = format!(
r#"{{"access_token":"{}","refresh_token":"{}"#,
wsl_access_canary(),
wsl_refresh_canary()
);
let error = read_credential_document(&mut truncated.as_bytes())
.expect_err("a truncated document is refused");
let rendered = format!("{error} {error:?} {:?}", error.remedy());
for (name, canary) in every_canary() {
assert!(
!rendered.contains(&canary),
"{name} was quoted back by the refusal: {rendered}"
);
}
}
#[test]
fn a_valid_pair_round_trips_into_a_rooted_store_with_its_renewal_intact() {
let root = tempfile::tempdir().expect("a temporary directory");
let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
.expect("a rooted store resolves");
let received = read_credential_document(
&mut document(&wsl_access_canary(), &wsl_refresh_canary()).as_bytes(),
)
.expect("a well-formed pair is accepted");
let mut report = Vec::new();
store_received_credential(&store, &received, &mut report).expect("the store takes it");
let held = store
.load()
.expect("the store reads back")
.expect("a credential is there");
let token = UserAccessToken::from_stored_document(&held);
assert_eq!(token.secret().expose_secret(), wsl_access_canary());
let renewal = token.renewal().expect("the refresh half survived the trip");
assert_eq!(
renewal.refresh_token().expose_secret(),
wsl_refresh_canary()
);
assert_eq!(
renewal.access_expires_at.map(|at| at.to_rfc3339()),
Some("2026-09-06T20:00:00+00:00".to_string()),
"the access token's expiry is renewal metadata and must survive"
);
assert_eq!(
renewal.refresh_expires_at.map(|at| at.to_rfc3339()),
Some("2027-03-05T12:00:00+00:00".to_string()),
"the refresh token's expiry is what says when an interactive sign-in is due"
);
let report = String::from_utf8(report).expect("the report is text");
assert!(report.contains("renews itself"), "got: {report}");
no_canary_escaped(&report, root.path(), &|path| path.contains("secrets"));
}
#[test]
fn a_pair_without_a_refresh_half_is_reported_as_unrenewable() {
let root = tempfile::tempdir().expect("a temporary directory");
let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
.expect("a rooted store resolves");
let received = read_credential_document(
&mut format!(r#"{{"access_token":"{}"}}"#, wsl_access_canary()).as_bytes(),
)
.expect("an access token alone is the shape an App with expiry off issues");
let mut report = Vec::new();
store_received_credential(&store, &received, &mut report).expect("the store takes it");
let report = String::from_utf8(report).expect("the report is text");
assert!(
report.contains("no renewal half") && report.contains("interactive sign-in"),
"an unrenewable credential must say what replaces it: {report}"
);
}
#[test]
fn a_store_that_refuses_leaves_nothing_and_says_which_store() {
let root = tempfile::tempdir().expect("a temporary directory");
let secrets = root.path().join("secrets");
std::fs::create_dir_all(&secrets).expect("the secrets directory is created");
std::fs::write(secrets.join("machine"), b"not a directory")
.expect("the scope directory is occupied");
let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
.expect("a rooted store still resolves; only the write fails");
let received = read_credential_document(
&mut document(&wsl_access_canary(), &wsl_refresh_canary()).as_bytes(),
)
.expect("the document itself is fine");
let mut report = Vec::new();
let error = store_received_credential(&store, &received, &mut report)
.expect_err("the store cannot take it");
assert_eq!(error.class(), Failure::SecretStore);
assert!(
error.remedy().is_some(),
"a store failure names what to do next: {error:?}"
);
assert!(
report.is_empty(),
"nothing is reported about a credential that was not stored"
);
no_canary_escaped(&format!("{error} {error:?}"), root.path(), &|_| false);
}
#[test]
fn the_broker_hands_the_document_to_the_sink_and_returns_only_metadata() {
let token = UserAccessToken::from_stored_document(&SecretString::from(document(
&wsl_access_canary(),
&wsl_refresh_canary(),
)));
let mut sink = RecordingSink::accepting();
let metadata = deliver_user_credential(&token, &mut sink).expect("the sink takes it");
assert_eq!(sink.calls, 1, "the document is sent exactly once");
let delivered = sink.delivered.expect("the sink received a document");
assert!(delivered.contains(&wsl_access_canary()));
assert!(delivered.contains(&wsl_refresh_canary()));
assert!(
!delivered.contains(&windows_access_canary())
&& !delivered.contains(&windows_refresh_canary()),
"nothing of the Windows credential may travel"
);
assert!(metadata.renewable);
assert!(metadata.access_expires_at.is_some());
assert!(metadata.refresh_expires_at.is_some());
let rendered = format!("{metadata} {metadata:?}");
for (name, canary) in every_canary() {
assert!(!rendered.contains(&canary), "{name} reached the metadata");
}
}
#[test]
fn the_broker_issues_a_new_pair_and_never_touches_the_active_windows_store() {
let root = tempfile::tempdir().expect("a temporary directory");
let windows_store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
.expect("a rooted store resolves");
let planted = document(&windows_access_canary(), &windows_refresh_canary());
windows_store
.store(&SecretString::from(planted.clone()))
.expect("the active Windows credential is in place");
let github = FakeDeviceFlow::approving();
let context = context_against(root.path(), &github);
let mut sink = RecordingSink::accepting();
let mut transcript = Vec::new();
let metadata =
broker_user_credential(&context, Styling::plain(), &mut transcript, &mut sink)
.expect("the broker issues and delivers a credential");
let delivered = sink.delivered.expect("the sink received a document");
assert!(
delivered.contains(&wsl_access_canary()) && delivered.contains(&wsl_refresh_canary()),
"the WSL host must receive the pair issued for it"
);
assert!(
!delivered.contains(&windows_access_canary())
&& !delivered.contains(&windows_refresh_canary()),
"`03-security-and-lifecycle.md` guarantee 2: the active Windows credential is \
never read for transfer"
);
assert!(metadata.renewable, "the fixture issued a refresh half");
let still_there = windows_store
.load()
.expect("the active store reads back")
.expect("the active credential is still there");
assert_eq!(
still_there.expose_secret(),
planted,
"the Windows store was written to by a handoff that has no business touching it"
);
let user_scope = PlatformSecretStore::rooted_at(SecretScope::User, root.path())
.expect("a rooted store resolves");
assert!(
user_scope.load().unwrap_or(None).is_none(),
"the handoff staged a credential in the other scope"
);
let transcript = String::from_utf8(transcript).expect("the transcript is text");
no_canary_escaped(&transcript, root.path(), &|path| path.contains("secrets"));
}
#[test]
fn the_broker_succeeds_when_the_active_windows_store_cannot_even_be_read() {
let root = tempfile::tempdir().expect("a temporary directory");
let planted = document(&windows_access_canary(), &windows_refresh_canary());
let windows_store = Arc::new(UnreadableStore::holding(&planted));
let github = FakeDeviceFlow::approving();
let context = context_against(root.path(), &github)
.with_secret_store(Arc::clone(&windows_store) as Arc<dyn SecretStore>);
let mut sink = RecordingSink::accepting();
let mut transcript = Vec::new();
broker_user_credential(&context, Styling::plain(), &mut transcript, &mut sink)
.expect("issuing a new credential does not depend on reading the old one");
let delivered = sink.delivered.expect("the sink received a document");
assert!(
delivered.contains(&wsl_access_canary()) && delivered.contains(&wsl_refresh_canary()),
"the WSL host still receives its own newly issued pair"
);
assert!(
!delivered.contains(&windows_access_canary())
&& !delivered.contains(&windows_refresh_canary()),
"`03-security-and-lifecycle.md` guarantee 2: the active Windows credential is \
never read for transfer"
);
assert_eq!(
windows_store.loads(),
0,
"`03-security-and-lifecycle.md` guarantee 2: the active Windows credential is \
never read for transfer, and the broker read it"
);
assert_eq!(
windows_store.writes(),
0,
"`03-security-and-lifecycle.md` guarantee 5: the handoff wrote to the active \
Windows store, which it has no business touching"
);
assert_eq!(
windows_store.deletes(),
0,
"the handoff removed the active Windows credential"
);
assert_eq!(
windows_store.held(),
planted,
"the Windows host's own credential is not what it was"
);
let transcript = String::from_utf8(transcript).expect("the transcript is text");
no_canary_escaped(&transcript, root.path(), &|_| false);
}
#[test]
fn a_device_flow_that_cannot_reach_github_stages_nothing() {
let root = tempfile::tempdir().expect("a temporary directory");
let context = context_against_nothing(root.path());
let mut sink = RecordingSink::accepting();
let mut transcript = Vec::new();
let error = broker_user_credential(&context, Styling::plain(), &mut transcript, &mut sink)
.expect_err("nothing is listening");
assert_eq!(error.class(), Failure::GithubUnavailable);
assert_eq!(
sink.calls, 0,
"a sink is not offered a credential there is none of"
);
no_credential_was_staged(root.path());
no_canary_escaped(
&String::from_utf8(transcript).expect("the transcript is text"),
root.path(),
&|_| false,
);
}
#[test]
fn a_declined_login_stages_nothing() {
let root = tempfile::tempdir().expect("a temporary directory");
let github = FakeDeviceFlow::start(r#"{"error":"access_denied"}"#);
let context = context_against(root.path(), &github);
let mut sink = RecordingSink::accepting();
let mut transcript = Vec::new();
let error = broker_user_credential(&context, Styling::plain(), &mut transcript, &mut sink)
.expect_err("the login was declined");
assert_eq!(error.class(), Failure::AuthenticationDeclined);
assert_eq!(sink.calls, 0);
assert!(
github.requests_answered() >= 2,
"the flow must actually have started and been polled, or this proves nothing"
);
no_credential_was_staged(root.path());
no_canary_escaped(
&String::from_utf8(transcript).expect("the transcript is text"),
root.path(),
&|_| false,
);
}
#[test]
fn a_sink_that_refuses_stages_nothing() {
for refusal in [
SecretSinkError::Undeliverable {
destination: "the WSL distribution `Ubuntu`".to_string(),
reason: "the distribution is not running".to_string(),
},
SecretSinkError::Refused {
destination: "the WSL distribution `Ubuntu`".to_string(),
reason: "its secret store is not writable by root".to_string(),
},
] {
let root = tempfile::tempdir().expect("a temporary directory");
let github = FakeDeviceFlow::approving();
let context = context_against(root.path(), &github);
let mut sink = RecordingSink::refusing(refusal);
let mut transcript = Vec::new();
let error =
broker_user_credential(&context, Styling::plain(), &mut transcript, &mut sink)
.expect_err("the sink refused");
assert_eq!(error.class(), Failure::SecretStore);
assert!(
error.remedy().is_some(),
"a handoff failure names what to do next: {error:?}"
);
assert!(
error.to_string().contains("Ubuntu"),
"the failure names the host that did not get it: {error}"
);
assert_eq!(sink.calls, 1, "the document was offered exactly once");
no_credential_was_staged(root.path());
no_canary_escaped(
&format!(
"{error} {error:?} {}",
String::from_utf8(transcript).expect("the transcript is text")
),
root.path(),
&|_| false,
);
}
}
#[test]
fn logout_states_that_the_authoritative_revocation_is_elsewhere() {
let notice = text(write_revocation_notice);
assert!(notice.contains(REVOCATION_HEADLINE), "got: {notice}");
assert!(
notice.contains("still valid at GitHub"),
"an operator must not read a local purge as a revocation: {notice}"
);
assert!(
notice.contains("https://github.com/settings/installations"),
"the notice must name where the revocation is actually done: {notice}"
);
}
}