From aaeb62efa4a7e08bc33aace31f67c66c08a64c51 Mon Sep 17 00:00:00 2001 From: michel Date: Mon, 20 Jul 2026 20:11:41 +0200 Subject: [PATCH] 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. --- Cargo.lock | 232 ++++++++++++++++++++++++++++-- soulseek-rs/Cargo.toml | 1 + soulseek-rs/src/main.rs | 10 +- soulseek-rs/src/persist/mod.rs | 1 + soulseek-rs/src/persist/secret.rs | 176 +++++++++++++++++++++++ 5 files changed, 409 insertions(+), 11 deletions(-) create mode 100644 soulseek-rs/src/persist/secret.rs diff --git a/Cargo.lock b/Cargo.lock index fbdde3e..ab73b06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/soulseek-rs/Cargo.toml b/soulseek-rs/Cargo.toml index 671fb81..d480321 100644 --- a/soulseek-rs/Cargo.toml +++ b/soulseek-rs/Cargo.toml @@ -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" diff --git a/soulseek-rs/src/main.rs b/soulseek-rs/src/main.rs index a8ec30c..81e40bb 100644 --- a/soulseek-rs/src/main.rs +++ b/soulseek-rs/src/main.rs @@ -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" ) })?; diff --git a/soulseek-rs/src/persist/mod.rs b/soulseek-rs/src/persist/mod.rs index 9fd0523..77fd6aa 100644 --- a/soulseek-rs/src/persist/mod.rs +++ b/soulseek-rs/src/persist/mod.rs @@ -6,3 +6,4 @@ pub mod config; pub mod paths; +pub mod secret; diff --git a/soulseek-rs/src/persist/secret.rs b/soulseek-rs/src/persist/secret.rs new file mode 100644 index 0000000..9b40b39 --- /dev/null +++ b/soulseek-rs/src/persist/secret.rs @@ -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>; + 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> { + 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 { + 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 { + 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>, + fail: bool, + } + + impl SecretStore for FakeStore { + fn get(&self, username: &str) -> Result> { + 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 + ); + } +}