Files
spotifast/src/credentials.rs
T

1289 lines
45 KiB
Rust

//! Durable Spotify grants and proxy passwords in the platform credential store.
//!
//! All native calls run on one dedicated thread. A locked store cannot hold
//! the UI, command loop, or runtime shutdown hostage. Generation checks reject
//! work from before sign-out, including a write that returns after sign-out.
//! Non-secret revocation markers prevent restoration after a failed deletion.
use std::path::{Path, PathBuf};
use std::sync::{
Arc, Mutex,
atomic::{AtomicU64, Ordering},
mpsc,
};
use std::time::Duration;
use librespot_core::authentication::Credentials;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::{auth::StoredToken, paths::AppDirs};
const SERVICE: &str = "rocks.spotifast.Spotifast";
const LEGACY_SERVICE: &str = "rocks.fastpotify.Fastpotify";
const TIMEOUT: Duration = Duration::from_secs(20);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Slot {
Shared,
Personal,
Playback,
Proxy,
}
impl Slot {
pub const SPOTIFY: [Self; 3] = [Self::Shared, Self::Personal, Self::Playback];
pub const ALL: [Self; 4] = [Self::Shared, Self::Personal, Self::Playback, Self::Proxy];
pub(crate) fn index(self) -> usize {
self as usize
}
fn name(self) -> &'static str {
match self {
Self::Shared => "shared-web",
Self::Personal => "personal-web",
Self::Playback => "playback",
Self::Proxy => "proxy-password",
}
}
}
/// Deliberately has no Debug implementation: it contains usable secrets.
#[derive(Clone, PartialEq, Serialize, Deserialize)]
pub enum Grant {
Web(StoredToken),
Playback(Credentials),
Proxy(ProxyPassword),
}
/// No Debug implementation: the password is usable, and the username is private.
/// A saved password belongs to one network endpoint and username.
#[derive(Clone, PartialEq, Serialize, Deserialize)]
pub struct ProxyPassword {
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
}
impl ProxyPassword {
fn valid(&self) -> bool {
let settings = crate::settings::Settings {
proxy_host: self.host.clone(),
proxy_port: self.port.to_string(),
proxy_username: self.username.clone(),
proxy_password: self.password.clone(),
..Default::default()
};
settings.proxy_password_record().ok().flatten().as_ref() == Some(self)
}
}
impl Grant {
fn valid_for(&self, slot: Slot) -> bool {
match (slot, self) {
(Slot::Shared, Self::Web(token)) => {
token.client_id == crate::auth::DEFAULT_WEB_CLIENT_ID
&& !token.refresh_token.is_empty()
}
(Slot::Personal, Self::Web(token)) => {
!token.client_id.is_empty() && !token.refresh_token.is_empty()
}
(Slot::Playback, Self::Playback(credentials)) => {
credentials
.username
.as_deref()
.is_some_and(|name| !name.is_empty())
&& !credentials.auth_data.is_empty()
}
(Slot::Proxy, Self::Proxy(password)) => password.valid(),
_ => false,
}
}
}
#[derive(Serialize, Deserialize)]
struct Record {
version: u32,
slot: Slot,
grant: Grant,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
pub enum Error {
#[error(
"The system credential store is unavailable. Unlock or enable it to remember this sign-in."
)]
Unavailable,
#[error(
"The system credential store is locked or access was denied. Unlock it to remember this sign-in."
)]
Locked,
#[error("The system credential store did not respond. This sign-in cannot be remembered yet.")]
Timeout,
#[error("The stored Spotify grant is invalid. Sign in again.")]
Invalid,
#[error(
"Unable to update credential-storage state. Check the application state directory's permissions."
)]
Filesystem,
#[error(
"The system credential store did not retain the grant. This sign-in cannot be remembered."
)]
Verification,
#[error("The sign-in changed while credential storage was in progress.")]
Stale,
}
impl Error {
pub(crate) fn proxy_message(self) -> &'static str {
match self {
Self::Unavailable | Self::Locked => {
"Unlock or enable the system credential store to remember the proxy password."
}
Self::Timeout => {
"The credential store did not respond. The proxy password is available only for this session."
}
Self::Invalid => {
"The stored proxy password or its endpoint is invalid. Enter the proxy settings again."
}
Self::Filesystem => {
"Unable to update proxy-password storage. Check the application state directory permissions."
}
Self::Verification => {
"The credential store did not retain the proxy password. It is available only for this session."
}
Self::Stale => "The proxy settings changed while the password was being stored.",
}
}
}
fn native_error(error: keyring_core::Error) -> Error {
// Some provider errors contain the secret or arbitrary platform data.
// Never propagate their Debug/Display text into application diagnostics.
match error {
keyring_core::Error::NoStorageAccess(_) => Error::Locked,
_ => Error::Unavailable,
}
}
trait ProtectedStore: Send {
fn read(&mut self, key: &str) -> Result<Option<Vec<u8>>, Error>;
fn write(&mut self, key: &str, secret: &[u8]) -> Result<(), Error>;
fn delete(&mut self, key: &str) -> Result<(), Error>;
}
#[derive(Default)]
struct NativeStore {
store: Option<Arc<keyring_core::api::CredentialStore>>,
legacy_profile: bool,
}
impl NativeStore {
fn entry(&mut self, key: &str) -> Result<keyring_core::Entry, Error> {
self.entry_in(
if self.legacy_profile {
LEGACY_SERVICE
} else {
SERVICE
},
key,
)
}
fn entry_in(&mut self, service: &str, key: &str) -> Result<keyring_core::Entry, Error> {
if self.store.is_none() {
#[cfg(target_os = "linux")]
let store = zbus_secret_service_keyring_store::Store::new();
#[cfg(target_os = "macos")]
let store = apple_native_keyring_store::keychain::Store::new();
#[cfg(windows)]
let store = windows_native_keyring_store::Store::new();
self.store = Some(store.map_err(native_error)?);
}
self.store
.as_ref()
.ok_or(Error::Unavailable)?
.build(service, key, None)
.map_err(native_error)
}
}
impl ProtectedStore for NativeStore {
fn read(&mut self, key: &str) -> Result<Option<Vec<u8>>, Error> {
match self.entry(key)?.get_secret() {
Ok(secret) => Ok(Some(secret)),
Err(keyring_core::Error::NoEntry) => {
let legacy = self.entry_in(LEGACY_SERVICE, key)?;
let secret = match legacy.get_secret() {
Ok(secret) => secret,
Err(keyring_core::Error::NoEntry) => return Ok(None),
Err(error) => return Err(native_error(error)),
};
let current = self.entry(key)?;
current.set_secret(&secret).map_err(native_error)?;
if current.get_secret().map_err(native_error)? != secret {
return Err(Error::Unavailable);
}
// Delete only after reading the replacement back successfully.
legacy.delete_credential().map_err(native_error)?;
Ok(Some(secret))
}
Err(error) => Err(native_error(error)),
}
}
fn write(&mut self, key: &str, secret: &[u8]) -> Result<(), Error> {
self.entry(key)?.set_secret(secret).map_err(native_error)
}
fn delete(&mut self, key: &str) -> Result<(), Error> {
for service in [SERVICE, LEGACY_SERVICE] {
match self.entry_in(service, key)?.delete_credential() {
Ok(()) | Err(keyring_core::Error::NoEntry) => (),
Err(error) => return Err(native_error(error)),
}
}
Ok(())
}
}
type Job = Box<dyn FnOnce(&mut dyn ProtectedStore) + Send>;
struct Inner {
dirs: AppDirs,
profile: String,
generations: [AtomicU64; 4],
// Held only around the tiny local marker files, never a native store call.
markers: Mutex<()>,
jobs: mpsc::SyncSender<Job>,
}
#[derive(Clone)]
pub struct Store {
inner: Arc<Inner>,
}
/// Permission to read/write one grant for one authorization generation.
#[derive(Clone)]
pub struct Lease {
store: Store,
slot: Slot,
generation: u64,
}
#[derive(Serialize, Deserialize)]
struct Marker {
version: u32,
revoked: bool,
}
pub struct Loaded {
pub grant: Option<Grant>,
pub warning: Option<Error>,
}
impl Store {
pub fn new(dirs: AppDirs) -> Self {
let backend = NativeStore {
legacy_profile: dirs.is_legacy_profile(),
..Default::default()
};
Self::with_backend(dirs, Box::new(backend))
}
#[cfg(test)]
pub(crate) fn in_memory(dirs: AppDirs) -> Self {
tests::memory_store(dirs)
}
fn with_backend(dirs: AppDirs, mut backend: Box<dyn ProtectedStore>) -> Self {
let (jobs, receiver) = mpsc::sync_channel::<Job>(16);
let runtime = tokio::runtime::Handle::try_current().ok();
let _ = std::thread::Builder::new()
.name("spotify-credentials".into())
.spawn(move || {
let _entered = runtime.as_ref().map(tokio::runtime::Handle::enter);
while let Ok(job) = receiver.recv() {
job(backend.as_mut());
}
});
let profile = std::fs::read_to_string(dirs.state.join("credential-profile"))
.ok()
.filter(|value| value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()))
.unwrap_or_else(|| {
format!(
"{:x}",
Sha256::digest(dirs.state.to_string_lossy().as_bytes())
)
});
Self {
inner: Arc::new(Inner {
dirs,
profile,
generations: std::array::from_fn(|_| AtomicU64::new(0)),
markers: Mutex::new(()),
jobs,
}),
}
}
pub fn lease(&self, slot: Slot) -> Lease {
Lease {
store: self.clone(),
slot,
generation: self.inner.generations[slot.index()].load(Ordering::SeqCst),
}
}
/// Invalidate in-flight work before canceling workers or deleting grants.
pub fn invalidate(&self, slot: Slot) {
self.inner.generations[slot.index()].fetch_add(1, Ordering::SeqCst);
}
/// Persist revocation before contacting the native store. Even a locked
/// keychain or an interrupted deletion must not sign the user back in.
pub fn revoke(&self, slot: Slot) -> Result<(), Error> {
self.invalidate(slot);
let _guard = self.inner.markers.lock().unwrap_or_else(|p| p.into_inner());
let marker = self.write_marker(slot, true);
let legacy = self.remove_legacy(slot);
marker.and(legacy)
}
pub fn revoke_spotify(&self) -> Result<(), Error> {
let mut error = None;
for slot in Slot::SPOTIFY {
if let Err(failure) = self.revoke(slot) {
error = Some(failure);
}
}
// Sign-out also removes corrupt/partial old combined records, whose
// client identity cannot be established by a migration reader.
let legacy = self.inner.dirs.legacy_web_token_file();
for path in [legacy.clone(), legacy.with_extension("json.tmp")] {
if let Err(failure) = remove_file(&path) {
error = Some(failure);
}
}
error.map_or(Ok(()), Err)
}
fn marker_path(&self, slot: Slot) -> PathBuf {
self.inner
.dirs
.state
.join("credential-storage")
.join(format!("{}.json", slot.name()))
}
fn marker(&self, slot: Slot) -> Result<Option<Marker>, Error> {
match std::fs::read(self.marker_path(slot)) {
Ok(bytes) => {
let marker: Marker = serde_json::from_slice(&bytes).map_err(|_| Error::Invalid)?;
if marker.version != 1 {
return Err(Error::Invalid);
}
Ok(Some(marker))
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(_) => Err(Error::Filesystem),
}
}
fn write_marker(&self, slot: Slot, revoked: bool) -> Result<(), Error> {
let path = self.marker_path(slot);
let parent = path.parent().ok_or(Error::Filesystem)?;
std::fs::create_dir_all(parent).map_err(|_| Error::Filesystem)?;
let bytes = serde_json::to_vec(&Marker {
version: 1,
revoked,
})
.map_err(|_| Error::Invalid)?;
let temporary = path.with_extension("json.tmp");
std::fs::write(&temporary, bytes).map_err(|_| Error::Filesystem)?;
crate::util::replace_file(&temporary, &path).map_err(|_| Error::Filesystem)
}
fn legacy_paths(&self, slot: Slot) -> Vec<PathBuf> {
if slot == Slot::Proxy {
let path = self.inner.dirs.proxy_secret_file();
return vec![path.clone(), path.with_extension("tmp")];
}
let dirs = &self.inner.dirs;
let path = match slot {
Slot::Shared => dirs.shared_web_token_file(),
Slot::Personal => dirs.personal_web_token_file(),
Slot::Playback => dirs.credentials_dir().join("credentials.json"),
Slot::Proxy => unreachable!("proxy legacy paths handled above"),
};
let mut paths = vec![path.clone(), path.with_extension("json.tmp")];
if slot != Slot::Playback {
// The old combined path belongs to whichever client identity it contains.
let legacy = dirs.legacy_web_token_file();
if StoredToken::load(&legacy).is_some_and(|token| {
(slot == Slot::Shared) == (token.client_id == crate::auth::DEFAULT_WEB_CLIENT_ID)
}) {
paths.push(legacy.clone());
paths.push(legacy.with_extension("json.tmp"));
}
}
paths
}
fn remove_legacy(&self, slot: Slot) -> Result<(), Error> {
let mut result = Ok(());
for path in self.legacy_paths(slot) {
if let Err(error) = remove_file(&path) {
result = Err(error);
}
}
result
}
fn legacy_proxy(&self) -> Result<Option<Grant>, Error> {
let text = match std::fs::read_to_string(self.inner.dirs.settings_file()) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return match self.inner.dirs.proxy_secret_file().try_exists() {
Ok(false) => Ok(None),
Ok(true) => Err(Error::Invalid),
Err(_) => Err(Error::Filesystem),
};
}
Err(_) => return Err(Error::Filesystem),
};
let mut settings: crate::settings::Settings =
serde_json::from_str(&text).map_err(|_| Error::Invalid)?;
settings.migrate_proxy(&text);
match std::fs::read_to_string(self.inner.dirs.proxy_secret_file()) {
Ok(password) => settings.proxy_password = password,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => return Err(Error::Filesystem),
}
settings
.proxy_password_record()
.map(|password| password.map(Grant::Proxy))
.map_err(|_| Error::Invalid)
}
fn legacy(&self, slot: Slot) -> Result<Option<Grant>, Error> {
if slot == Slot::Proxy {
return self.legacy_proxy();
}
for path in self
.legacy_paths(slot)
.into_iter()
.filter(|path| path.extension().is_some_and(|ext| ext == "json"))
{
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(_) => return Err(Error::Filesystem),
};
let grant = if slot == Slot::Playback {
Grant::Playback(serde_json::from_slice(&bytes).map_err(|_| Error::Invalid)?)
} else {
Grant::Web(serde_json::from_slice(&bytes).map_err(|_| Error::Invalid)?)
};
if !grant.valid_for(slot) {
return Err(Error::Invalid);
}
return Ok(Some(grant));
}
Ok(None)
}
}
fn remove_file(path: &Path) -> Result<(), Error> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(_) => Err(Error::Filesystem),
}
}
impl Lease {
pub fn current(&self) -> bool {
self.store.inner.generations[self.slot.index()].load(Ordering::SeqCst) == self.generation
}
pub fn generation(&self) -> u64 {
self.generation
}
fn check(&self) -> Result<(), Error> {
if self.current() {
Ok(())
} else {
Err(Error::Stale)
}
}
fn key(&self) -> String {
format!("{}:{}", self.store.inner.profile, self.slot.name())
}
fn request<T: Send + 'static>(
&self,
guard_generation: bool,
operation: impl FnOnce(Self, &mut dyn ProtectedStore) -> Result<T, Error> + Send + 'static,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<T, Error>> + Send>> {
let pending = (|| {
if guard_generation {
self.check()?;
}
let (sender, receiver) = tokio::sync::oneshot::channel();
let lease = self.clone();
self.store
.inner
.jobs
.try_send(Box::new(move |backend| {
let result = (if guard_generation {
lease.check()
} else {
Ok(())
})
.and_then(|()| operation(lease.clone(), backend));
let result = result.and_then(|value| {
if guard_generation {
lease.check()?;
}
Ok(value)
});
let _ = sender.send(result);
}))
.map_err(|_| Error::Unavailable)?;
Ok::<_, Error>(receiver)
})();
Box::pin(async move {
tokio::time::timeout(TIMEOUT, pending?)
.await
.map_err(|_| Error::Timeout)?
.map_err(|_| Error::Unavailable)?
})
}
pub async fn load(&self) -> Result<Loaded, Error> {
self.request(true, |lease, backend| {
let marker = lease.store.marker(lease.slot)?;
if marker.as_ref().is_some_and(|marker| marker.revoked) {
return Ok(Loaded {
grant: None,
warning: None,
});
}
let existing = backend.read(&lease.key());
if let Ok(Some(bytes)) = &existing {
let record: Record = serde_json::from_slice(bytes).map_err(|_| Error::Invalid)?;
if record.version != 1
|| record.slot != lease.slot
|| !record.grant.valid_for(lease.slot)
{
return Err(Error::Invalid);
}
let _guard = lease
.store
.inner
.markers
.lock()
.unwrap_or_else(|p| p.into_inner());
lease.check()?;
lease.store.write_marker(lease.slot, false)?;
let warning = lease.store.remove_legacy(lease.slot).err();
return Ok(Loaded {
grant: Some(record.grant),
warning,
});
}
if marker.is_some() {
return existing.map(|_| Loaded {
grant: None,
warning: None,
});
}
let Some(grant) = lease.store.legacy(lease.slot)? else {
return existing.map(|_| Loaded {
grant: None,
warning: None,
});
};
// Keep a failed migration recoverable. No new plaintext is written,
// and the caller must show the warning about persistence.
let warning = match existing {
Err(error) => Some(error),
Ok(_) => lease.save_inner(backend, grant.clone()).err(),
};
Ok(Loaded {
grant: Some(grant),
warning,
})
})
.await
}
fn save_inner(&self, backend: &mut dyn ProtectedStore, grant: Grant) -> Result<(), Error> {
self.check()?;
if !grant.valid_for(self.slot) {
return Err(Error::Invalid);
}
let bytes = serde_json::to_vec(&Record {
version: 1,
slot: self.slot,
grant,
})
.map_err(|_| Error::Invalid)?;
backend.write(&self.key(), &bytes)?;
if !self.current() {
// Operations on this store are serialized. No newer write can be
// erased here; it is still queued behind this one.
let _ = backend.delete(&self.key());
return Err(Error::Stale);
}
if backend.read(&self.key())?.as_deref() != Some(bytes.as_slice()) {
return Err(Error::Verification);
}
let _guard = self
.store
.inner
.markers
.lock()
.unwrap_or_else(|p| p.into_inner());
self.check()?;
self.store.write_marker(self.slot, false)?;
self.store.remove_legacy(self.slot)
}
/// Enqueue immediately so refreshes preserve write order even when callers
/// await completion outside their token mutex.
pub fn save(
&self,
grant: Grant,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), Error>> + Send>> {
self.request(true, move |lease, backend| lease.save_inner(backend, grant))
}
/// Call `Store::revoke` before this. Deletion failure remains visible while
/// the local revocation marker prevents restoration on the next launch.
pub fn delete(
&self,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), Error>> + Send>> {
// Deletion is enqueued before any replacement write. Run it even if a
// subsequent authorization increments the generation while it waits.
self.request(false, |lease, backend| backend.delete(&lease.key()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[derive(Default)]
struct Fake {
values: HashMap<String, Vec<u8>>,
read_error: Option<Error>,
write_error: Option<Error>,
delete_error: Option<Error>,
discard_write: bool,
entered: Option<tokio::sync::oneshot::Sender<()>>,
release: Option<mpsc::Receiver<()>>,
}
pub(super) fn memory_store(dirs: AppDirs) -> Store {
Store::with_backend(
dirs,
Box::new(Backend(Arc::new(Mutex::new(Fake::default())))),
)
}
struct Backend(Arc<Mutex<Fake>>);
impl ProtectedStore for Backend {
fn read(&mut self, key: &str) -> Result<Option<Vec<u8>>, Error> {
let fake = self.0.lock().unwrap();
if let Some(error) = fake.read_error {
return Err(error);
}
Ok(fake.values.get(key).cloned())
}
fn write(&mut self, key: &str, value: &[u8]) -> Result<(), Error> {
let (entered, release) = {
let mut fake = self.0.lock().unwrap();
if let Some(error) = fake.write_error {
return Err(error);
}
(fake.entered.take(), fake.release.take())
};
if let Some(entered) = entered {
let _ = entered.send(());
}
if let Some(release) = release {
release.recv().unwrap();
}
let mut fake = self.0.lock().unwrap();
if !fake.discard_write {
fake.values.insert(key.to_owned(), value.to_vec());
}
Ok(())
}
fn delete(&mut self, key: &str) -> Result<(), Error> {
let mut fake = self.0.lock().unwrap();
if let Some(error) = fake.delete_error {
return Err(error);
}
fake.values.remove(key);
Ok(())
}
}
struct Fixture {
dirs: AppDirs,
fake: Arc<Mutex<Fake>>,
store: Store,
}
impl Fixture {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let root = std::env::temp_dir().join(format!(
"spotifast-credential-tests-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
let dirs = AppDirs {
config: root.join("config"),
state: root.join("state"),
cache: root.join("cache"),
};
dirs.ensure().unwrap();
let fake = Arc::new(Mutex::new(Fake::default()));
let store = Store::with_backend(dirs.clone(), Box::new(Backend(fake.clone())));
Self { dirs, fake, store }
}
fn restart(&self) -> Store {
Store::with_backend(self.dirs.clone(), Box::new(Backend(self.fake.clone())))
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(self.dirs.state.parent().unwrap());
}
}
fn web(client: &str) -> Grant {
Grant::Web(StoredToken {
client_id: client.into(),
access_token: "dummy-access".into(),
refresh_token: "dummy-refresh".into(),
expires_at: u64::MAX,
scope: "dummy-scope".into(),
})
}
fn playback() -> Grant {
Grant::Playback(Credentials { username: Some("dummy-account".into()), auth_data: b"dummy-reusable-grant".to_vec(), auth_type: librespot_protocol::authentication::AuthenticationType::AUTHENTICATION_STORED_SPOTIFY_CREDENTIALS })
}
#[tokio::test]
async fn renamed_profile_keeps_grants_and_revocation_markers() {
let fixture = Fixture::new();
let grant = web(crate::auth::DEFAULT_WEB_CLIENT_ID);
fixture
.store
.lease(Slot::Shared)
.save(grant.clone())
.await
.unwrap();
fixture.store.revoke(Slot::Playback).unwrap();
let parent = fixture.dirs.state.parent().unwrap();
let renamed = AppDirs {
config: parent.join("new-config"),
state: parent.join("new-state"),
cache: parent.join("new-cache"),
};
renamed.migrate_from(&fixture.dirs).unwrap();
let store = Store::with_backend(renamed, Box::new(Backend(fixture.fake.clone())));
assert_eq!(store.inner.profile, fixture.store.inner.profile);
assert!(store.lease(Slot::Shared).load().await.unwrap().grant == Some(grant));
assert!(
store
.lease(Slot::Playback)
.load()
.await
.unwrap()
.grant
.is_none()
);
}
fn proxy_password() -> Grant {
Grant::Proxy(ProxyPassword {
host: "127.0.0.1".into(),
port: 8080,
username: "dummy-user".into(),
password: " dummy-private-password\n".into(),
})
}
fn write_legacy(path: &Path, grant: &Grant) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let bytes = match grant {
Grant::Web(token) => serde_json::to_vec(token).unwrap(),
Grant::Playback(credentials) => serde_json::to_vec(credentials).unwrap(),
Grant::Proxy(password) => password.password.as_bytes().to_vec(),
};
std::fs::write(path, bytes).unwrap();
}
fn write_proxy_legacy(f: &Fixture, combined: bool) -> Grant {
let Grant::Proxy(password) = proxy_password() else {
unreachable!()
};
let settings = crate::settings::Settings {
proxy_mode: crate::settings::ProxyMode::Http,
proxy_host: password.host.clone(),
proxy_port: password.port.to_string(),
proxy_username: password.username.clone(),
..Default::default()
};
let mut json = serde_json::to_value(settings).unwrap();
if combined {
json["proxy_password"] = password.password.clone().into();
}
std::fs::write(f.dirs.settings_file(), serde_json::to_vec(&json).unwrap()).unwrap();
if !combined {
std::fs::write(f.dirs.proxy_secret_file(), password.password.as_bytes()).unwrap();
}
Grant::Proxy(password)
}
#[tokio::test]
async fn proxy_password_migration_verifies_before_removing_legacy_data() {
for combined in [false, true] {
let f = Fixture::new();
let expected = write_proxy_legacy(&f, combined);
let settings_before = std::fs::read(f.dirs.settings_file()).unwrap();
let loaded = f.store.lease(Slot::Proxy).load().await.unwrap();
assert!(loaded.grant == Some(expected.clone()));
assert_eq!(loaded.warning, None);
assert!(!f.dirs.proxy_secret_file().exists());
// Settings belong to the UI thread. A verified event permits its
// next atomic save to remove the legacy JSON password.
assert_eq!(
std::fs::read(f.dirs.settings_file()).unwrap(),
settings_before
);
assert!(f.restart().lease(Slot::Proxy).load().await.unwrap().grant == Some(expected));
}
}
#[tokio::test]
async fn a_failed_proxy_migration_retains_the_exact_original_and_can_retry() {
for combined in [false, true] {
let f = Fixture::new();
let expected = write_proxy_legacy(&f, combined);
let path = if combined {
f.dirs.settings_file()
} else {
f.dirs.proxy_secret_file()
};
let before = std::fs::read(&path).unwrap();
f.fake.lock().unwrap().write_error = Some(Error::Locked);
let loaded = f.store.lease(Slot::Proxy).load().await.unwrap();
assert!(
loaded.grant == Some(expected.clone()),
"legacy password still serves this session"
);
assert_eq!(loaded.warning, Some(Error::Locked));
assert_eq!(std::fs::read(&path).unwrap(), before);
f.fake.lock().unwrap().write_error = None;
let loaded = f.restart().lease(Slot::Proxy).load().await.unwrap();
assert!(loaded.grant == Some(expected));
assert_eq!(loaded.warning, None);
}
}
#[tokio::test]
async fn spotify_sign_out_retains_the_network_password() {
let f = Fixture::new();
let password = proxy_password();
f.store
.lease(Slot::Proxy)
.save(password.clone())
.await
.unwrap();
f.store
.lease(Slot::Shared)
.save(web(crate::auth::DEFAULT_WEB_CLIENT_ID))
.await
.unwrap();
f.store.revoke_spotify().unwrap();
let restarted = f.restart();
assert!(restarted.lease(Slot::Proxy).load().await.unwrap().grant == Some(password));
assert!(
restarted
.lease(Slot::Shared)
.load()
.await
.unwrap()
.grant
.is_none()
);
}
#[tokio::test]
async fn a_failed_proxy_delete_cannot_restore_the_forgotten_password() {
let f = Fixture::new();
let password = write_proxy_legacy(&f, true);
f.store.lease(Slot::Proxy).save(password).await.unwrap();
f.fake.lock().unwrap().delete_error = Some(Error::Locked);
f.store.revoke(Slot::Proxy).unwrap();
assert_eq!(
f.store.lease(Slot::Proxy).delete().await,
Err(Error::Locked)
);
assert!(
f.restart()
.lease(Slot::Proxy)
.load()
.await
.unwrap()
.grant
.is_none()
);
}
#[tokio::test]
#[ignore = "requires an unlocked platform credential store; uses dummy grants only"]
async fn native_store_round_trip() {
let f = Fixture::new();
let key = f.store.lease(Slot::Shared).key();
tokio::task::spawn_blocking(move || {
let mut native = NativeStore::default();
let legacy = native.entry_in(LEGACY_SERVICE, &key).unwrap();
legacy.set_secret(b"dummy-rename-probe").unwrap();
let mut trial = NativeStore {
legacy_profile: true,
..Default::default()
};
assert_eq!(
trial.read(&key).unwrap().as_deref(),
Some(b"dummy-rename-probe".as_slice())
);
assert_eq!(legacy.get_secret().unwrap(), b"dummy-rename-probe");
assert!(matches!(
native.entry(&key).unwrap().get_secret(),
Err(keyring_core::Error::NoEntry)
));
assert_eq!(
native.read(&key).unwrap().as_deref(),
Some(b"dummy-rename-probe".as_slice())
);
assert!(matches!(
legacy.get_secret(),
Err(keyring_core::Error::NoEntry)
));
assert_eq!(
native.entry(&key).unwrap().get_secret().unwrap(),
b"dummy-rename-probe"
);
native.delete(&key).unwrap();
})
.await
.unwrap();
let store = Store::new(f.dirs.clone());
let grants = [
web(crate::auth::DEFAULT_WEB_CLIENT_ID),
web("dummy-personal-client"),
playback(),
proxy_password(),
];
for (slot, grant) in Slot::ALL.into_iter().zip(grants.iter()) {
store.lease(slot).save(grant.clone()).await.unwrap();
}
let restarted = Store::new(f.dirs.clone());
for (slot, grant) in Slot::ALL.into_iter().zip(grants) {
assert!(restarted.lease(slot).load().await.unwrap().grant == Some(grant));
}
store.revoke_spotify().unwrap();
store.revoke(Slot::Proxy).unwrap();
for slot in Slot::ALL {
let lease = store.lease(slot);
lease.delete().await.unwrap();
assert!(
lease
.request(false, |lease, backend| backend.read(&lease.key()))
.await
.unwrap()
.is_none()
);
}
}
#[tokio::test]
async fn queued_deletion_cannot_erase_a_new_authorization() {
let f = Fixture::new();
let old = web("dummy-old-personal-client");
let new = web("dummy-new-personal-client");
f.store.lease(Slot::Personal).save(old).await.unwrap();
f.store.revoke(Slot::Personal).unwrap();
let deletion = f.store.lease(Slot::Personal).delete();
f.store.invalidate(Slot::Personal);
let saving = f.store.lease(Slot::Personal).save(new.clone());
deletion.await.unwrap();
saving.await.unwrap();
assert!(
f.restart()
.lease(Slot::Personal)
.load()
.await
.unwrap()
.grant
== Some(new)
);
}
#[test]
fn a_failed_revocation_marker_does_not_prevent_legacy_cleanup() {
let f = Fixture::new();
let path = f.dirs.shared_web_token_file();
write_legacy(&path, &web(crate::auth::DEFAULT_WEB_CLIENT_ID));
std::fs::write(f.dirs.state.join("credential-storage"), b"not a directory").unwrap();
assert_eq!(f.store.revoke(Slot::Shared), Err(Error::Filesystem));
assert!(!path.exists());
}
#[tokio::test]
async fn all_grants_migrate_and_survive_restart_without_plaintext_files() {
let f = Fixture::new();
let grants = [
web(crate::auth::DEFAULT_WEB_CLIENT_ID),
web("dummy-personal-client"),
playback(),
];
let paths = [
f.dirs.shared_web_token_file(),
f.dirs.personal_web_token_file(),
f.dirs.credentials_dir().join("credentials.json"),
];
for ((slot, grant), path) in Slot::SPOTIFY
.into_iter()
.zip(grants.iter())
.zip(paths.iter())
{
write_legacy(path, grant);
let loaded = f.store.lease(slot).load().await.unwrap();
assert!(loaded.grant.as_ref() == Some(grant));
assert_eq!(loaded.warning, None);
assert!(!path.exists());
let marker = std::fs::read_to_string(f.store.marker_path(slot)).unwrap();
assert!(!marker.contains("dummy"));
}
let restarted = f.restart();
for (slot, grant) in Slot::SPOTIFY.into_iter().zip(grants) {
assert!(restarted.lease(slot).load().await.unwrap().grant == Some(grant));
}
assert_eq!(f.fake.lock().unwrap().values.len(), 3);
}
#[tokio::test]
async fn combined_legacy_path_migrates_to_its_client_identity() {
for (slot, client) in [
(Slot::Shared, crate::auth::DEFAULT_WEB_CLIENT_ID),
(Slot::Personal, "dummy-personal-client"),
] {
let f = Fixture::new();
let grant = web(client);
write_legacy(&f.dirs.legacy_web_token_file(), &grant);
assert!(f.store.lease(slot).load().await.unwrap().grant == Some(grant));
assert!(!f.dirs.legacy_web_token_file().exists());
}
}
#[tokio::test]
async fn failed_migration_keeps_original_and_reports_warning_then_can_retry() {
let f = Fixture::new();
let grant = web(crate::auth::DEFAULT_WEB_CLIENT_ID);
let path = f.dirs.shared_web_token_file();
write_legacy(&path, &grant);
f.fake.lock().unwrap().write_error = Some(Error::Locked);
let loaded = f.store.lease(Slot::Shared).load().await.unwrap();
assert!(loaded.grant == Some(grant.clone()));
assert_eq!(loaded.warning, Some(Error::Locked));
assert!(path.exists());
assert!(!f.store.marker_path(Slot::Shared).exists());
f.fake.lock().unwrap().write_error = None;
let loaded = f.store.lease(Slot::Shared).load().await.unwrap();
assert!(loaded.grant == Some(grant));
assert_eq!(loaded.warning, None);
assert!(!path.exists());
}
#[tokio::test]
async fn migration_requires_read_back_before_removing_original() {
let f = Fixture::new();
write_legacy(
&f.dirs.shared_web_token_file(),
&web(crate::auth::DEFAULT_WEB_CLIENT_ID),
);
f.fake.lock().unwrap().discard_write = true;
let loaded = f.store.lease(Slot::Shared).load().await.unwrap();
assert_eq!(loaded.warning, Some(Error::Verification));
assert!(f.dirs.shared_web_token_file().exists());
assert!(!f.store.marker_path(Slot::Shared).exists());
}
#[tokio::test]
async fn late_write_after_signout_cannot_recreate_grant() {
let f = Fixture::new();
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = mpsc::channel();
{
let mut fake = f.fake.lock().unwrap();
fake.entered = Some(entered_tx);
fake.release = Some(release_rx);
}
let lease = f.store.lease(Slot::Shared);
let pending_lease = lease.clone();
let pending = tokio::spawn(async move {
pending_lease
.save(web(crate::auth::DEFAULT_WEB_CLIENT_ID))
.await
});
entered_rx.await.unwrap();
f.store.revoke_spotify().unwrap();
assert!(!lease.current());
release_tx.send(()).unwrap();
assert_eq!(pending.await.unwrap(), Err(Error::Stale));
assert!(f.fake.lock().unwrap().values.is_empty());
assert!(
f.restart()
.lease(Slot::Shared)
.load()
.await
.unwrap()
.grant
.is_none()
);
}
#[tokio::test]
async fn failed_native_deletion_remains_revoked_across_restart() {
let f = Fixture::new();
f.store
.lease(Slot::Shared)
.save(web(crate::auth::DEFAULT_WEB_CLIENT_ID))
.await
.unwrap();
f.fake.lock().unwrap().delete_error = Some(Error::Locked);
f.store.revoke_spotify().unwrap();
assert_eq!(
f.store.lease(Slot::Shared).delete().await,
Err(Error::Locked)
);
assert_eq!(f.fake.lock().unwrap().values.len(), 1);
// A revoked load must not even require access to the locked service.
f.fake.lock().unwrap().read_error = Some(Error::Locked);
assert!(
f.restart()
.lease(Slot::Shared)
.load()
.await
.unwrap()
.grant
.is_none()
);
}
#[tokio::test]
async fn revoked_or_protected_slots_never_revive_stale_legacy_files() {
let f = Fixture::new();
let grant = web(crate::auth::DEFAULT_WEB_CLIENT_ID);
f.store
.lease(Slot::Shared)
.save(grant.clone())
.await
.unwrap();
f.fake.lock().unwrap().values.clear();
write_legacy(&f.dirs.shared_web_token_file(), &grant);
assert!(
f.restart()
.lease(Slot::Shared)
.load()
.await
.unwrap()
.grant
.is_none()
);
f.store.revoke_spotify().unwrap();
write_legacy(&f.dirs.shared_web_token_file(), &grant);
assert!(
f.restart()
.lease(Slot::Shared)
.load()
.await
.unwrap()
.grant
.is_none()
);
}
#[tokio::test]
async fn signout_removes_partial_and_corrupt_legacy_files_and_can_repeat() {
let f = Fixture::new();
let paths = [
f.dirs.shared_web_token_file(),
f.dirs.personal_web_token_file(),
f.dirs.legacy_web_token_file(),
f.dirs.credentials_dir().join("credentials.json"),
];
for path in &paths {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, b"corrupt-dummy-grant").unwrap();
std::fs::write(path.with_extension("json.tmp"), b"partial-dummy-grant").unwrap();
}
f.store.revoke_spotify().unwrap();
f.store.revoke_spotify().unwrap();
for path in paths {
assert!(!path.exists());
assert!(!path.with_extension("json.tmp").exists());
}
}
#[tokio::test]
async fn profiles_are_isolated_and_invalid_records_are_rejected() {
let f = Fixture::new();
let other = Fixture::new();
let isolated = Store::with_backend(other.dirs.clone(), Box::new(Backend(f.fake.clone())));
f.store
.lease(Slot::Shared)
.save(web(crate::auth::DEFAULT_WEB_CLIENT_ID))
.await
.unwrap();
assert!(
isolated
.lease(Slot::Shared)
.load()
.await
.unwrap()
.grant
.is_none()
);
for bytes in [
b"not-json".to_vec(),
serde_json::to_vec(&Record {
version: 2,
slot: Slot::Shared,
grant: web(crate::auth::DEFAULT_WEB_CLIENT_ID),
})
.unwrap(),
serde_json::to_vec(&Record {
version: 1,
slot: Slot::Playback,
grant: playback(),
})
.unwrap(),
] {
f.fake
.lock()
.unwrap()
.values
.insert(f.store.lease(Slot::Shared).key(), bytes);
assert!(matches!(
f.store.lease(Slot::Shared).load().await,
Err(Error::Invalid)
));
}
}
}