feat: password resolution via OS keychain and password_cmd

SecretStore trait with a keyring-backed implementation (macOS Keychain,
Windows Credential Manager, Linux Secret Service) under service
soulseek-rs. Precedence: --password/env > keychain > password_cmd from
config.toml. Keychain failures degrade to not-found so headless boxes
fall through to password_cmd.
This commit is contained in:
michel
2026-07-20 20:11:41 +02:00
parent 86e3d8c58f
commit aaeb62efa4
5 changed files with 409 additions and 11 deletions
Generated
+223 -9
View File
@@ -68,7 +68,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -79,7 +79,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -199,6 +199,12 @@ version = "1.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.12.1"
@@ -334,6 +340,32 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -439,6 +471,27 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "dbus"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e"
dependencies = [
"libc",
"libdbus-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "dbus-secret-service"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
dependencies = [
"dbus",
"zeroize",
]
[[package]]
name = "deltae"
version = "0.3.2"
@@ -501,7 +554,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -549,7 +602,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -959,6 +1012,21 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "keyring"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"dbus-secret-service",
"log",
"security-framework 2.11.1",
"security-framework 3.7.0",
"windows-sys 0.60.2",
"zeroize",
]
[[package]]
name = "lab"
version = "0.11.0"
@@ -977,6 +1045,15 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libdbus-sys"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
dependencies = [
"pkg-config",
]
[[package]]
name = "libm"
version = "0.2.16"
@@ -1098,7 +1175,7 @@ dependencies = [
"libc",
"log",
"wasi",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1354,6 +1431,12 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "portable-atomic"
version = "1.14.0"
@@ -1612,7 +1695,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1633,6 +1716,42 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.13.1",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.1",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "semver"
version = "1.0.28"
@@ -1769,6 +1888,7 @@ dependencies = [
"directories",
"dotenv",
"igd-next",
"keyring",
"ratatui",
"serde",
"serde_json",
@@ -1863,7 +1983,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1876,7 +1996,7 @@ dependencies = [
"parking_lot",
"rustix",
"signal-hook",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1886,7 +2006,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -2370,6 +2490,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -2379,6 +2508,71 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.15"
@@ -2462,6 +2656,26 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zerotrie"
version = "0.2.4"
+1
View File
@@ -28,6 +28,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.9"
directories = "6"
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
[dev-dependencies]
tempfile = "3"
+8 -2
View File
@@ -51,9 +51,15 @@ fn main() -> Result<()> {
)
})?;
let password = cli.password.clone().ok_or_else(|| {
let password = persist::secret::resolve_password(
cli.password.as_deref(),
resolved.username.as_deref(),
resolved.password_cmd.as_deref(),
&persist::secret::KeyringStore,
)
.ok_or_else(|| {
color_eyre::eyre::eyre!(
"Password required: use --password or set SOULSEEK_PASSWORD env var"
"Password required: use --password, set SOULSEEK_PASSWORD, or store one in the OS keychain"
)
})?;
+1
View File
@@ -6,3 +6,4 @@
pub mod config;
pub mod paths;
pub mod secret;
+176
View File
@@ -0,0 +1,176 @@
use color_eyre::Result;
/// Where passwords live. The real implementation is the OS keychain
/// ([`KeyringStore`]); tests use an in-memory fake. Passwords are never
/// written to config.toml.
pub trait SecretStore {
fn get(&self, username: &str) -> Result<Option<String>>;
fn set(&self, username: &str, password: &str) -> Result<()>;
}
/// OS keychain (macOS Keychain / Windows Credential Manager / Linux Secret
/// Service) under service name `soulseek-rs`, account = Soulseek username.
pub struct KeyringStore;
const SERVICE: &str = "soulseek-rs";
impl SecretStore for KeyringStore {
fn get(&self, username: &str) -> Result<Option<String>> {
let entry = keyring::Entry::new(SERVICE, username)
.map_err(|e| color_eyre::eyre::eyre!("keyring: {e}"))?;
match entry.get_password() {
Ok(password) => Ok(Some(password)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(color_eyre::eyre::eyre!("keyring: {e}")),
}
}
fn set(&self, username: &str, password: &str) -> Result<()> {
keyring::Entry::new(SERVICE, username)
.and_then(|entry| entry.set_password(password))
.map_err(|e| color_eyre::eyre::eyre!("keyring: {e}"))
}
}
/// Resolve the password with precedence: CLI/env (`cli_password`, already
/// merged by clap) > OS keychain > `password_cmd` from config.toml. Returns
/// `None` when no source has one — the caller shows the login screen.
///
/// Keychain errors are downgraded to "not found": on headless boxes without
/// a secret service the fallbacks must still work.
pub fn resolve_password(
cli_password: Option<&str>,
username: Option<&str>,
password_cmd: Option<&str>,
store: &dyn SecretStore,
) -> Option<String> {
if let Some(password) = cli_password {
return Some(password.to_string());
}
if let Some(username) = username
&& let Ok(Some(password)) = store.get(username)
{
return Some(password);
}
password_cmd.and_then(run_password_cmd)
}
/// Run `password_cmd` through the platform shell and return trimmed stdout.
fn run_password_cmd(cmd: &str) -> Option<String> {
let output = if cfg!(windows) {
std::process::Command::new("cmd").args(["/C", cmd]).output()
} else {
std::process::Command::new("sh").args(["-c", cmd]).output()
}
.ok()?;
if !output.status.success() {
return None;
}
let password = String::from_utf8(output.stdout).ok()?;
let password = password.trim_end_matches(['\r', '\n']);
(!password.is_empty()).then(|| password.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::collections::HashMap;
#[derive(Default)]
struct FakeStore {
secrets: RefCell<HashMap<String, String>>,
fail: bool,
}
impl SecretStore for FakeStore {
fn get(&self, username: &str) -> Result<Option<String>> {
if self.fail {
return Err(color_eyre::eyre::eyre!("no secret service"));
}
Ok(self.secrets.borrow().get(username).cloned())
}
fn set(&self, username: &str, password: &str) -> Result<()> {
self.secrets
.borrow_mut()
.insert(username.into(), password.into());
Ok(())
}
}
#[test]
fn cli_password_wins_over_everything() {
let store = FakeStore::default();
store.set("alice", "from-keyring").unwrap();
let got = resolve_password(
Some("from-cli"),
Some("alice"),
Some("echo from-cmd"),
&store,
);
assert_eq!(got.as_deref(), Some("from-cli"));
}
#[test]
fn keyring_used_when_no_cli_password() {
let store = FakeStore::default();
store.set("alice", "from-keyring").unwrap();
let got = resolve_password(None, Some("alice"), None, &store);
assert_eq!(got.as_deref(), Some("from-keyring"));
}
#[cfg(unix)]
#[test]
fn password_cmd_used_when_keyring_has_nothing() {
let store = FakeStore::default();
let got = resolve_password(
None,
Some("alice"),
Some("echo from-cmd"),
&store,
);
assert_eq!(got.as_deref(), Some("from-cmd"));
}
#[cfg(unix)]
#[test]
fn keyring_failure_falls_through_to_password_cmd() {
let store = FakeStore {
fail: true,
..FakeStore::default()
};
let got = resolve_password(
None,
Some("alice"),
Some("echo from-cmd"),
&store,
);
assert_eq!(got.as_deref(), Some("from-cmd"));
}
#[test]
fn none_when_no_source_has_a_password() {
let store = FakeStore::default();
assert_eq!(resolve_password(None, Some("alice"), None, &store), None);
}
#[test]
fn no_username_means_no_keyring_lookup() {
let store = FakeStore {
fail: true,
..FakeStore::default()
};
assert_eq!(resolve_password(None, None, None, &store), None);
}
#[cfg(unix)]
#[test]
fn failing_password_cmd_yields_none() {
let store = FakeStore::default();
assert_eq!(
resolve_password(None, Some("alice"), Some("false"), &store),
None
);
}
}