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

nordisk/gunnar

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());
        }
    }
}