deploy/upcloud/src/token.rs
//! Finding the UpCloud API token **by the name of the key it lives under**, and
//! never printing it.
//!
//! # WHY THIS IS NOT A SHAPE SCAN ANY MORE — THE DEFECT, AND ITS BLAST RADIUS
//!
//! This module used to scan a whole file for the `ucat_…` SHAPE and take the
//! FIRST hit. MEASURED 2026-09-20: `~/gunnar_secrets` — the site's one secrets
//! file, the `secrets_path()` convention every other door here uses — holds
//! **seven** `ucat_` strings:
//!
//! ```text
//! UPCLOUD_TOKEN_HENTOR <- a shape scan takes THIS one
//! UPCLOUD_TOKEN_RICKARD_NJORD
//! (one inside a comment)
//! UPCLOUD_TOKEN_HENTOR_MONETIZE
//! UPCLOUD_TOKEN <- the one this estate means, FIFTH
//! (another inside a comment)
//! MONETIZE_UPCLOUD_TOKEN
//! ```
//!
//! A token picked by shape **does not fail**. It authenticates perfectly,
//! against somebody else's account, and then the verb runs. The verbs this
//! repository ships are `cargo xtask down`, `upcloud-orphans --delete`, `grow`
//! and this re-image — destroy an estate, delete servers and storages, resize
//! real volumes, wipe a box and install over it. And the second key in that
//! list is `UPCLOUD_TOKEN_RICKARD_NJORD`: another account altogether, which
//! held a live estate with a production database when this rule was written
//! (that product is retired since 2026-10-25; the key name stays in the file).
//!
//! So the failure mode was never "the wrong thing errors". It was **the right
//! command running beautifully in the wrong place**, with a `200 OK` at every
//! step — which is the same family as every other defect this tree found today:
//! a check that cannot fail, reporting health.
//!
//! # THE RULE
//!
//! **A credential is selected by NAME. Shape is a VALIDATOR, never a selector.**
//! Shape can answer "is this a token"; it can never answer "*whose*".
//!
//! * [`find_named`] takes the key the caller means — `estate.toml`'s
//! `[estate] token_key` for anything holding a spec, `UPCLOUD_TOKEN` by
//! default here — and looks it up in the environment first, then under that
//! exact key in the file.
//! * A file that offers **more than one** candidate and does not carry the
//! named key is a **REFUSAL BY NAME**: it says how many it found, lists the
//! KEY NAMES it saw, and says which key to name. Which account an operator
//! means is the operator's decision, and a scanner that guesses is a scanner
//! that guesses about another account.
//! * Only once a key is named is the value checked for plausibility, and a key
//! that is PRESENT BUT MALFORMED refuses differently from one that is ABSENT:
//! they are different mistakes and they have different fixes.
//!
//! # THE ONE FILE THAT KEEPS THE OLD BEHAVIOUR, AND WHY IT IS SAFE
//!
//! The original `~/upload.txt` is a single-purpose file: an UpCloud upload
//! credential sheet with the bearer on a line of its own, under a username and
//! a password, carrying no `KEY=` at all. A file like that offers exactly ONE
//! candidate and that candidate is UNKEYED, so there is no name to be wrong
//! about and nothing to confuse it with. That case — **one candidate, unkeyed,
//! whole file** — still resolves without a key being named.
//!
//! Everything else does not. In particular a file holding ONE token under some
//! OTHER key (`UPCLOUD_TOKEN_HENTOR=…` and nothing else) is refused rather than
//! accepted, because a file that names an account is a file that means that
//! account, and quietly using it for this one is the whole defect in miniature.
//!
//! `$UPCLOUD_TOKEN` — or whichever key is named — still wins over the file, so
//! an operator on another box, or a future secret store, needs no change here.
/// The prefix UpCloud gives an API token.
pub const PREFIX: &str = "ucat_";
/// The key an UpCloud token lives under when nobody says otherwise. The same
/// spelling as `private-gunnar-ops`' `default_token_key()`, and the same one
/// `estate.toml`'s `[estate] token_key` overrides — so a cloud-account switch
/// is one line there rather than an edit in here.
pub const DEFAULT_KEY: &str = "UPCLOUD_TOKEN";
/// Where a token came from, for the log line. Never the token itself.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Source {
Env(String),
File {
path: String,
line: usize,
/// The key it sat under, or `None` for a bare token on a line of its
/// own in a single-purpose file.
key: Option<String>,
},
}
pub struct Found {
pub token: String,
pub source: Source,
}
/// **`Debug` is hand-written and it REDACTS.**
///
/// `#[derive(Debug)]` here would put a live bearer into every `unwrap()` panic,
/// every `{:?}` in a log line somebody adds in a hurry, and every test failure
/// message — including CI output, which is the one place a secret must never
/// reach. A derive is one word and it would have been silently correct-looking
/// forever. The struct exists to carry a credential, so the only safe `Debug`
/// is one that cannot print it.
impl std::fmt::Debug for Found {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Found")
.field("token", &redact(&self.token))
.field("source", &self.source)
.finish()
}
}
/// A token-shaped value in a credentials file, **named**.
///
/// The value is deliberately not public: a `Candidate` is what gets listed in a
/// refusal, and a refusal must be able to say everything it needs to say
/// without any part of a secret crossing into it.
#[derive(Clone, PartialEq, Eq)]
pub struct Candidate {
pub key: Option<String>,
pub line: usize,
value: String,
}
/// Private is not the same as unprintable: a derived `Debug` prints private
/// fields too. See [`Found`]'s `Debug` for why this is hand-written.
impl std::fmt::Debug for Candidate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Candidate")
.field("key", &self.key)
.field("line", &self.line)
.field("value", &redact(&self.value))
.finish()
}
}
impl Candidate {
/// What this candidate is called, for a message a human reads. Never a
/// value and never a fragment of one.
pub fn name(&self) -> String {
match &self.key {
Some(k) => k.clone(),
None => format!("<unnamed, line {}>", self.line),
}
}
}
/// **The validator.** Is this value shaped like an UpCloud bearer at all?
///
/// `ucat_` on its own is not a token, and anything shorter than the prefix plus
/// a few characters is a truncated paste — and a truncated bearer produces a
/// `401` that names nothing. This is the whole job shape has now: it says
/// whether a value the OPERATOR named is plausible. It never chooses.
pub fn looks_like_token(value: &str) -> bool {
value.starts_with(PREFIX)
&& value.len() > PREFIX.len() + 8
&& value[PREFIX.len()..]
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
/// Every token-shaped value in the text, with the key it sat under.
///
/// `KEY=value` lines are parsed the way `private-gunnar-ops`' `read_secrets_at`
/// parses them — `#` starts a comment line, blanks are skipped, a leading
/// `export ` is tolerated, surrounding quotes are stripped — so the two readers
/// of the same file agree about what is in it. A line that carries no `=` but
/// does carry a token is an UNKEYED candidate: that is the single-purpose
/// `~/upload.txt` shape and nothing else.
///
/// A commented-out token is NOT a candidate. `~/gunnar_secrets` carries two of
/// those, both of them dead keys kept for the note beside them, and a scanner
/// that offered a revoked token as a live one would be answering a question
/// nobody asked.
pub fn candidates(text: &str) -> Vec<Candidate> {
let mut out = Vec::new();
for (i, raw) in text.lines().enumerate() {
let line = i + 1;
let t = raw.trim();
if t.is_empty() || t.starts_with('#') {
continue;
}
let t = t.strip_prefix("export ").map(str::trim).unwrap_or(t);
match t.split_once('=') {
Some((k, v)) => {
let k = k.trim();
let plausible_key = !k.is_empty()
&& k.bytes()
.next()
.is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
&& k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
let v = v.trim().trim_matches(['"', '\'']).trim();
if plausible_key && looks_like_token(v) {
out.push(Candidate {
key: Some(k.to_string()),
line,
value: v.to_string(),
});
}
}
None => {
// A bare token on a line of its own, possibly with other words
// around it — the `~/upload.txt` shape.
if let Some(start) = t.find(PREFIX) {
let rest = &t[start..];
let end = rest
.char_indices()
.find(|(_, c)| !(c.is_ascii_alphanumeric() || *c == '_' || *c == '-'))
.map(|(i, _)| i)
.unwrap_or(rest.len());
let v = &rest[..end];
if looks_like_token(v) {
out.push(Candidate {
key: None,
line,
value: v.to_string(),
});
}
}
}
}
}
out
}
/// The token under [`DEFAULT_KEY`].
pub fn find(path: &std::path::Path) -> Result<Found, String> {
find_named(path, DEFAULT_KEY)
}
/// **The token under the key the caller NAMES**: `$<key>` if it is set, else
/// that exact key in the file.
///
/// See the module header for why this does not search by shape, and what the
/// one surviving unnamed case is.
pub fn find_named(path: &std::path::Path, key: &str) -> Result<Found, String> {
if let Ok(v) = std::env::var(key) {
let v = v.trim().to_string();
if !v.is_empty() {
if !looks_like_token(&v) {
return Err(format!(
"${key} is set but is not shaped like an UpCloud token (it does not start \
`{PREFIX}`, or it is truncated). A truncated bearer answers `401` with \
nothing in it about where it came from, so it is refused here instead. \
Unset ${key} to fall back to {}.",
path.display()
));
}
return Ok(Found {
token: v,
source: Source::Env(key.to_string()),
});
}
}
let text = std::fs::read_to_string(path).map_err(|e| {
format!(
"no UpCloud token: ${key} is unset and {} cannot be read ({e})",
path.display()
)
})?;
let found = candidates(&text);
// 1. The key was named and it is there. The only path that needs no
// reasoning about what else the file holds.
if let Some(c) = found.iter().find(|c| c.key.as_deref() == Some(key)) {
return Ok(Found {
token: c.value.clone(),
source: Source::File {
path: path.display().to_string(),
line: c.line,
key: c.key.clone(),
},
});
}
// 2. Named but malformed is a DIFFERENT mistake from absent, and it has a
// different fix: the key is there and the value under it is wrong.
if let Some(line) = malformed_line(&text, key) {
return Err(format!(
"{} line {line} carries `{key}=` but the value under it is not shaped like an \
UpCloud token — it does not start `{PREFIX}`, or it is empty, or it is a \
truncated paste. The key is right and the VALUE is wrong, so re-paste it; \
nothing else in the file is used in its place.",
path.display()
));
}
let names: Vec<String> = found.iter().map(Candidate::name).collect();
match found.len() {
0 => Err(format!(
"no `{key}` and no token-shaped value anywhere in {} ({} lines read). Put the \
UpCloud API token there as `{key}=…`, or export ${key}.",
path.display(),
text.lines().count()
)),
// 3. The single-purpose file: one candidate, and it is UNKEYED, so
// there is no name to be wrong about. See the module header.
1 if found[0].key.is_none() => Ok(Found {
token: found[0].value.clone(),
source: Source::File {
path: path.display().to_string(),
line: found[0].line,
key: None,
},
}),
// 4. Anything else is the operator's decision, not this scanner's.
_ => Err(format!(
"REFUSED: {} carries no `{key}`, but it does carry {} other token{} — under {}. \
This door will NOT pick one by shape: they are different UpCloud ACCOUNTS, and a \
token chosen wrongly does not fail, it succeeds somewhere it should never have \
run. Name the key you mean with --token-key <KEY>, or export ${key}.",
path.display(),
found.len(),
if found.len() == 1 { "" } else { "s" },
names.join(", ")
)),
}
}
/// The 1-based line on which `key=` appears with a value that is NOT a token,
/// so "present but wrong" can be told from "absent". Comment lines do not count.
fn malformed_line(text: &str, key: &str) -> Option<usize> {
for (i, raw) in text.lines().enumerate() {
let t = raw.trim();
if t.is_empty() || t.starts_with('#') {
continue;
}
let t = t.strip_prefix("export ").map(str::trim).unwrap_or(t);
if let Some((k, _)) = t.split_once('=') {
if k.trim() == key {
return Some(i + 1);
}
}
}
None
}
/// What may be printed: the prefix and the last four characters.
pub fn redact(token: &str) -> String {
if token.len() <= PREFIX.len() + 4 {
format!("{PREFIX}…")
} else {
format!("{PREFIX}…{}", &token[token.len() - 4..])
}
}
/// A direct-upload URL is itself a credential — it is `https://…/uploader/session/<secret>`
/// and anyone holding it can write the storage. It is logged through this.
pub fn redact_url(url: &str) -> String {
match url.rfind('/') {
Some(i) if i + 1 < url.len() => format!("{}/…", &url[..i]),
_ => url.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Not a real token and not the shape of one either beyond the prefix —
/// nothing in this module's tests is a credential, and nothing printed by
/// them is a fragment of one.
const FAKE: &str = "ucat_AbCd1234EfGh5678";
const FAKE_B: &str = "ucat_ZzZz9999YyYy8888";
fn write(name: &str, text: &str) -> std::path::PathBuf {
let p = std::env::temp_dir().join(format!("gunnar-token-{}-{name}", std::process::id()));
std::fs::write(&p, text).expect("fixture");
p
}
// ── the single-purpose file keeps working ───────────────────────────────
/// `~/upload.txt`: the bearer on a line of its own under a username and a
/// password, no `KEY=` anywhere. One candidate, unkeyed, so there is no
/// name to be wrong about.
#[test]
fn the_single_purpose_upload_file_still_resolves_without_a_key() {
let p = write(
"single",
&format!("# upcloud\nusername: operator\npassword: something\n{FAKE}\n"),
);
let f = find_named(&p, DEFAULT_KEY).unwrap();
assert_eq!(f.token, FAKE);
match f.source {
Source::File { line, key, .. } => {
assert_eq!(line, 4);
assert_eq!(key, None);
}
s => panic!("{s:?}"),
}
let _ = std::fs::remove_file(&p);
}
/// Red-first, MEASURED with `text.lines().nth(3)` as the implementation:
/// one comment line added at the top and the bearer became a password.
#[test]
fn a_line_added_at_the_top_does_not_move_the_token() {
let p = write(
"moved",
&format!("# added today\n# upcloud\nusername: operator\npassword: x\n{FAKE}\n"),
);
assert_eq!(find_named(&p, DEFAULT_KEY).unwrap().token, FAKE);
let _ = std::fs::remove_file(&p);
}
/// A named key resolves, and the name is what did it — not the position.
/// The decoy sits FIRST and is the one the old shape scan would have taken.
#[test]
fn a_named_key_wins_over_whatever_comes_first() {
let p = write(
"named",
&format!("UPCLOUD_TOKEN_HENTOR={FAKE_B}\nUPCLOUD_TOKEN={FAKE}\n"),
);
let f = find_named(&p, DEFAULT_KEY).unwrap();
assert_eq!(f.token, FAKE, "the FIRST token in the file must not win");
match f.source {
Source::File { key, .. } => assert_eq!(key.as_deref(), Some("UPCLOUD_TOKEN")),
s => panic!("{s:?}"),
}
// …and naming the other key reaches the other account on purpose.
assert_eq!(
find_named(&p, "UPCLOUD_TOKEN_HENTOR").unwrap().token,
FAKE_B
);
let _ = std::fs::remove_file(&p);
}
// ── the defect, refused ─────────────────────────────────────────────────
/// **THE ONE THAT MATTERS.** The real `~/gunnar_secrets` shape: seven
/// `ucat_` strings, two of them commented out, and the key this estate
/// means is the fifth. With no key named, the old code took
/// `UPCLOUD_TOKEN_HENTOR` — a different account — and succeeded.
#[test]
fn a_multi_account_file_with_no_named_key_refuses_and_lists_the_names() {
let text = format!(
"# secrets\n\
UPCLOUD_TOKEN_HENTOR={FAKE_B}\n\
UPCLOUD_TOKEN_RICKARD_NJORD={FAKE_B}\n\
# note: an UPCLOUD token ({FAKE_B}), NOT cloudflare\n\
UPCLOUD_TOKEN_HENTOR_MONETIZE={FAKE_B}\n\
MONETIZE_UPCLOUD_TOKEN={FAKE_B}\n"
);
let p = write("multi", &text);
// The commented-out one is not offered as live.
assert_eq!(candidates(&text).len(), 4);
let err = find_named(&p, DEFAULT_KEY).unwrap_err();
assert!(err.contains("REFUSED"), "{err}");
assert!(err.contains("UPCLOUD_TOKEN_HENTOR"), "{err}");
assert!(err.contains("UPCLOUD_TOKEN_RICKARD_NJORD"), "{err}");
assert!(err.contains("MONETIZE_UPCLOUD_TOKEN"), "{err}");
assert!(
err.contains("--token-key"),
"it must say how to fix it: {err}"
);
assert!(
!err.contains(FAKE_B) && !err.contains("ZzZz"),
"a refusal must never carry a value: {err}"
);
let _ = std::fs::remove_file(&p);
}
/// The same file, with the key named, resolves — the refusal is about
/// AMBIGUITY, not about the file being large.
#[test]
fn the_same_file_resolves_the_moment_the_key_is_named() {
let p = write(
"multi-ok",
&format!(
"UPCLOUD_TOKEN_HENTOR={FAKE_B}\n\
UPCLOUD_TOKEN_RICKARD_NJORD={FAKE_B}\n\
UPCLOUD_TOKEN={FAKE}\n\
MONETIZE_UPCLOUD_TOKEN={FAKE_B}\n"
),
);
assert_eq!(find_named(&p, DEFAULT_KEY).unwrap().token, FAKE);
let _ = std::fs::remove_file(&p);
}
/// A file holding ONE token under SOMEBODY ELSE'S key is refused, not
/// adopted. A file that names an account means that account.
#[test]
fn one_token_under_another_accounts_key_is_still_refused() {
let p = write("other", &format!("UPCLOUD_TOKEN_RICKARD_NJORD={FAKE_B}\n"));
let err = find_named(&p, DEFAULT_KEY).unwrap_err();
assert!(err.contains("REFUSED"), "{err}");
assert!(err.contains("UPCLOUD_TOKEN_RICKARD_NJORD"), "{err}");
let _ = std::fs::remove_file(&p);
}
// ── present-but-wrong is not the same mistake as absent ─────────────────
#[test]
fn a_named_key_that_is_present_but_malformed_refuses_differently_from_absent() {
let bad = write("bad", "UPCLOUD_TOKEN=ucat_12\n");
let e1 = find_named(&bad, DEFAULT_KEY).unwrap_err();
assert!(e1.contains("the value under it is not shaped"), "{e1}");
assert!(e1.contains("line 1"), "{e1}");
assert!(
!e1.contains("REFUSED:"),
"this is not the ambiguity refusal: {e1}"
);
let absent = write("absent", "# nothing here\nSOMETHING_ELSE=hello\n");
let e2 = find_named(&absent, DEFAULT_KEY).unwrap_err();
assert!(e2.contains("no token-shaped value anywhere"), "{e2}");
assert_ne!(e1, e2, "absent and malformed must not read the same");
let _ = std::fs::remove_file(&bad);
let _ = std::fs::remove_file(&absent);
}
#[test]
fn a_truncated_paste_is_not_a_token() {
assert!(!looks_like_token("ucat_"));
assert!(!looks_like_token("ucat_12"));
assert!(!looks_like_token(""));
assert!(!looks_like_token("cfut_AbCd1234EfGh5678"));
assert!(looks_like_token(FAKE));
assert!(candidates("ucat_\nucat_12\n").is_empty());
}
#[test]
fn export_and_quotes_are_tolerated_the_way_the_ops_reader_tolerates_them() {
let p = write("export", &format!("export UPCLOUD_TOKEN=\"{FAKE}\"\n"));
assert_eq!(find_named(&p, DEFAULT_KEY).unwrap().token, FAKE);
let _ = std::fs::remove_file(&p);
}
// ── nothing is ever printed ─────────────────────────────────────────────
#[test]
fn nothing_ever_prints_the_token() {
let shown = redact(FAKE);
assert_eq!(shown, "ucat_…5678");
assert!(!shown.contains("AbCd1234"), "{shown}");
assert_eq!(
redact_url("https://fi-hel1.img.upcloud.com/uploader/session/9f2b3c"),
"https://fi-hel1.img.upcloud.com/uploader/session/…"
);
}
/// A derived `Debug` on either of these would put a live bearer into every
/// `unwrap()` panic and every CI log. Both are hand-written; this is what
/// keeps them that way.
#[test]
fn debug_redacts_on_both_credential_carriers() {
let f = Found {
token: FAKE.to_string(),
source: Source::Env("UPCLOUD_TOKEN".to_string()),
};
let shown = format!("{f:?}");
assert!(shown.contains("ucat_…5678"), "{shown}");
assert!(
!shown.contains("AbCd1234"),
"a Debug leaked the token: {shown}"
);
let c = candidates(&format!("UPCLOUD_TOKEN={FAKE}\n"));
let shown = format!("{c:?}");
assert!(shown.contains("UPCLOUD_TOKEN"), "{shown}");
assert!(
!shown.contains("AbCd1234"),
"a Debug leaked the token: {shown}"
);
}
/// A `Candidate` can be named in a message without its value being
/// reachable — the field is private, and this is what says so out loud.
#[test]
fn a_candidate_names_itself_without_its_value() {
let c = candidates(&format!("UPCLOUD_TOKEN={FAKE}\n"));
assert_eq!(c[0].name(), "UPCLOUD_TOKEN");
let unnamed = candidates(&format!("{FAKE}\n"));
assert_eq!(unnamed[0].name(), "<unnamed, line 1>");
for x in c.iter().chain(unnamed.iter()) {
assert!(!x.name().contains("ucat_"), "{}", x.name());
}
}
}