spotify_cli/storage/
token_store.rs1use std::fs;
13use std::path::PathBuf;
14use thiserror::Error;
15
16use super::paths;
17use crate::oauth::token::Token;
18
19#[derive(Debug, Error)]
21pub enum TokenStoreError {
22 #[error("Could not determine config directory: {0}")]
23 Path(#[from] paths::PathError),
24
25 #[error("Failed to create directory: {0}")]
26 CreateDir(#[from] std::io::Error),
27
28 #[error("Failed to serialize token: {0}")]
29 Serialize(#[from] serde_json::Error),
30
31 #[error("Token not found")]
32 NotFound,
33}
34
35pub struct TokenStore {
39 path: PathBuf,
40}
41
42impl TokenStore {
43 pub fn new() -> Result<Self, TokenStoreError> {
45 let path = paths::token_file()?;
46 Ok(Self { path })
47 }
48
49 pub fn save(&self, token: &Token) -> Result<(), TokenStoreError> {
54 if let Some(parent) = self.path.parent() {
55 fs::create_dir_all(parent)?;
56 }
57
58 let json = serde_json::to_string_pretty(token)?;
59 fs::write(&self.path, json)?;
60
61 self.set_secure_permissions();
63
64 Ok(())
65 }
66
67 #[cfg(unix)]
72 fn set_secure_permissions(&self) {
73 use std::os::unix::fs::PermissionsExt;
74 use tracing::warn;
75
76 if let Ok(metadata) = fs::metadata(&self.path) {
77 let mut perms = metadata.permissions();
78 perms.set_mode(0o600); if let Err(e) = fs::set_permissions(&self.path, perms) {
80 warn!(path = %self.path.display(), error = %e, "Failed to set secure permissions on token file");
81 }
82 }
83 }
84
85 #[cfg(not(unix))]
86 fn set_secure_permissions(&self) {
87 }
91
92 pub fn load(&self) -> Result<Token, TokenStoreError> {
96 if !self.path.exists() {
97 return Err(TokenStoreError::NotFound);
98 }
99
100 let json = fs::read_to_string(&self.path)?;
101 let token = serde_json::from_str(&json)?;
102
103 Ok(token)
104 }
105
106 pub fn delete(&self) -> Result<(), TokenStoreError> {
108 if self.path.exists() {
109 fs::remove_file(&self.path)?;
110 }
111
112 Ok(())
113 }
114
115 pub fn exists(&self) -> bool {
117 self.path.exists()
118 }
119
120 pub fn path(&self) -> &PathBuf {
122 &self.path
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use crate::oauth::token::SpotifyTokenResponse;
130 use std::env;
131
132 const TOKEN_FILE: &str = "token.json";
133
134 fn temp_store() -> TokenStore {
135 let temp_dir = env::temp_dir().join(format!("spotify-cli-test-{}", rand::random::<u64>()));
136 TokenStore {
137 path: temp_dir.join(TOKEN_FILE),
138 }
139 }
140
141 fn make_token() -> Token {
142 Token::from_response(SpotifyTokenResponse {
143 access_token: "test_access".to_string(),
144 token_type: "Bearer".to_string(),
145 scope: "user-read-playback-state".to_string(),
146 expires_in: 3600,
147 refresh_token: Some("test_refresh".to_string()),
148 })
149 }
150
151 #[test]
152 fn save_and_load_token() {
153 let store = temp_store();
154 let token = make_token();
155
156 store.save(&token).unwrap();
157 let loaded = store.load().unwrap();
158
159 assert_eq!(loaded.access_token, token.access_token);
160 assert_eq!(loaded.refresh_token, token.refresh_token);
161
162 store.delete().unwrap();
163 }
164
165 #[test]
166 fn load_nonexistent_returns_not_found() {
167 let store = temp_store();
168 let result = store.load();
169
170 assert!(matches!(result, Err(TokenStoreError::NotFound)));
171 }
172
173 #[test]
174 fn exists_returns_false_when_no_token() {
175 let store = temp_store();
176 assert!(!store.exists());
177 }
178
179 #[test]
180 fn exists_returns_true_after_save() {
181 let store = temp_store();
182 let token = make_token();
183
184 store.save(&token).unwrap();
185 assert!(store.exists());
186
187 store.delete().unwrap();
188 }
189
190 #[cfg(unix)]
191 #[test]
192 fn save_sets_secure_permissions() {
193 use std::os::unix::fs::PermissionsExt;
194
195 let store = temp_store();
196 let token = make_token();
197
198 store.save(&token).unwrap();
199
200 let metadata = fs::metadata(store.path()).unwrap();
201 let mode = metadata.permissions().mode();
202
203 assert_eq!(
206 mode & 0o777,
207 0o600,
208 "Token file should have 0600 permissions"
209 );
210
211 store.delete().unwrap();
212 }
213
214 #[test]
215 fn delete_nonexistent_is_ok() {
216 let store = temp_store();
217 let result = store.delete();
218 assert!(result.is_ok());
219 }
220
221 #[test]
222 fn path_returns_correct_path() {
223 let store = temp_store();
224 assert!(store.path().to_str().unwrap().contains("token.json"));
225 }
226
227 #[test]
228 fn token_store_error_display() {
229 let err = TokenStoreError::NotFound;
230 let display = format!("{}", err);
231 assert!(display.contains("not found"));
232 }
233}