OXYGENHUB

oxy config never prints a secret whole 8e9c64d16ded

A token (remote.*.token, or any key naming a secret or password) is
confirmed as its first ten characters and how many more, never the
rest; '-' reads the value from standard input so it stays out of the
shell's history.

Closes #113
Author
Andy McCall @andymccall <[email protected]>
via
agent Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
When
· 2026-10-15 14:17 UTC
Signature
✔ signed by Claude Code
Hash
8e9c64d16ded10f83455c73635f23906f0098ce76eb0760b15fed866715a1e00
Depends on
614f55e14eee 7aa2dc9369e3 fce44ae9ad29
Depended on by
nothing yet
In bookmarks
carried-not-removed doctor-trust identical-sides keyless-tags main merge-remote-bookmark paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
7 atoms, 0 tree operations, 2 files

Browse the tree at this change

modified src/cli/identity.rs +50 −1
…
406 unchanged lines
407 407
    Ok(if bad > 0 { 1 } else { 0 })
408 408
}
409 409
410 +
/// Whether a configuration key holds a secret, never to be printed.
411 +
fn secret_key(key: &str) -> bool {
412 +
    let k = key.to_ascii_lowercase();
413 +
    k.ends_with(".token") || k.contains("secret") || k.contains("password")
414 +
}
415 +
416 +
/// A secret as a confirmation may show it (oxygen#113): enough of its start
417 +
/// to tell which one it is, never the rest.
418 +
fn mask(value: &str) -> String {
419 +
    let shown: String = value.chars().take(10).collect();
420 +
    let rest = value.chars().count().saturating_sub(10);
421 +
    if rest == 0 {
422 +
        "…".to_string()
423 +
    } else {
424 +
        format!("{shown}…({rest} more)")
425 +
    }
426 +
}
427 +
410 428
pub fn config(key: &str, value: &str, user: bool, unset: bool) -> Result<i32> {
411 429
    // An empty value removes the key, which is what --unset says plainly.
412 -
    let value = if unset { "" } else { value };
430 +
    // `-` reads the value from standard input, so a token never sits in
431 +
    // the shell's history (oxygen#113).
432 +
    let read;
433 +
    let value = if unset {
434 +
        ""
435 +
    } else if value == "-" {
436 +
        let mut line = String::new();
437 +
        std::io::stdin().read_line(&mut line)?;
438 +
        read = line.trim().to_string();
439 +
        read.as_str()
440 +
    } else {
441 +
        value
442 +
    };
413 443
    let said = if unset {
414 444
        format!("{} {key}", ui::done("Unset"))
445 +
    } else if secret_key(key) {
446 +
        format!("{} {key} = {}", ui::done("Set"), mask(value))
415 447
    } else {
416 448
        format!("{} {key} = {value}", ui::done("Set"))
417 449
    };
…
471 unchanged lines
889 921
890 922
fn hex_string(b: &[u8]) -> String {
891 923
    b.iter().map(|x| format!("{x:02x}")).collect()
924 +
}
925 +
926 +
#[cfg(test)]
927 +
mod tests {
928 +
    #[test]
929 +
    fn secrets_are_never_printed_whole() {
930 +
        assert!(
931 +
            super::secret_key("remote.earth.token")
932 +
                && super::secret_key("auth.secret")
933 +
                && !super::secret_key("remote.earth.url")
934 +
        );
935 +
        assert_eq!(
936 +
            super::mask("oxyhub_6b7dce13409b634d"),
937 +
            "oxyhub_6b7…(13 more)"
938 +
        );
939 +
        assert_eq!(super::mask("short"), "…");
940 +
    }
892 941
}
modified src/cli/mod.rs +1 −1
…
476 unchanged lines
477 477
    Config {
478 478
        /// The key, such as author.name or remote.origin.token
479 479
        key: String,
480 -
        /// The value to set (none with --unset)
480 +
        /// The value to set (none with --unset); `-` reads it from standard input, out of the shell's history
481 481
        #[arg(required_unless_present = "unset")]
482 482
        value: Option<String>,
483 483
        /// Write to the user file (~/.config/oxy/config.toml) instead of this repository
…
895 unchanged lines