Skip to main content

qobuz_api/
credentials.rs

1//! Credential management: `.env` file I/O, web player extraction.
2//!
3//! Web player extraction lives in [`web`].
4
5pub mod web;
6
7#[cfg(unix)]
8use std::{
9    fs::{Permissions, set_permissions},
10    os::unix::fs::PermissionsExt,
11};
12
13use std::{
14    fs::{read_to_string, write},
15    io::Result as IoResult,
16    path::Path,
17};
18
19use serde::{Deserialize, Serialize};
20
21use crate::errors::QobuzApiError::{self, CredentialsError};
22
23/// User authentication credentials for the Qobuz API.
24///
25/// All fields are optional to accommodate different authentication methods.
26#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
27pub struct Credential {
28    /// Qobuz user ID.
29    pub user_id: Option<String>,
30    /// Authentication token.
31    pub user_auth_token: Option<String>,
32    /// Email address.
33    pub email: Option<String>,
34    /// MD5-hashed password.
35    pub password: Option<String>,
36    /// Application ID.
37    pub app_id: Option<String>,
38    /// Application secret.
39    pub app_secret: Option<String>,
40}
41
42/// Reads `QOBUZ_APP_ID` and `QOBUZ_APP_SECRET` from a `.env` file.
43///
44/// # Arguments
45///
46/// * `path` - Path to the `.env` file
47///
48/// # Returns
49///
50/// `Some((app_id, app_secret))` if both values are found, `None` otherwise.
51///
52/// # Errors
53///
54/// Returns `QobuzApiError::CredentialsError` if the file cannot be read or parsed.
55pub fn load_app_credentials(path: &Path) -> Result<Option<(String, String)>, QobuzApiError> {
56    if !path.exists() {
57        return Ok(None);
58    }
59
60    let mut app_id = None;
61    let mut app_secret = None;
62
63    for (key, value) in parse_env_file(path).map_err(|e| CredentialsError {
64        message: format!("Failed to read .env file: {e}"),
65    })? {
66        match key.as_str() {
67            "QOBUZ_APP_ID" => app_id = Some(value),
68            "QOBUZ_APP_SECRET" => app_secret = Some(value),
69            _ => {}
70        }
71    }
72
73    match (app_id, app_secret) {
74        (Some(id), Some(secret)) => Ok(Some((id, secret))),
75        _ => Ok(None),
76    }
77}
78
79/// Writes `QOBUZ_APP_ID` and `QOBUZ_APP_SECRET` to a `.env` file with `0600` permissions.
80///
81/// # Arguments
82///
83/// * `path` - Path to the `.env` file
84/// * `app_id` - Application ID to store
85/// * `app_secret` - Application secret to store
86///
87/// # Returns
88///
89/// `Ok(())` on success.
90///
91/// # Errors
92///
93/// Returns `QobuzApiError::IoError` if the file cannot be written or permissions set.
94pub fn save_app_credentials(
95    path: &Path,
96    app_id: &str,
97    app_secret: &str,
98) -> Result<(), QobuzApiError> {
99    let existing = if path.exists() {
100        read_to_string(path)?
101    } else {
102        String::new()
103    };
104
105    let lines: Vec<&str> = existing.lines().collect();
106    let mut updated_lines: Vec<String> = lines.iter().map(ToString::to_string).collect();
107    let mut id_written = false;
108    let mut secret_written = false;
109
110    for line in &mut updated_lines {
111        if line.starts_with("QOBUZ_APP_ID=") || line == "# QOBUZ_APP_ID=" {
112            *line = format!("QOBUZ_APP_ID={app_id}");
113            id_written = true;
114        }
115        if line.starts_with("QOBUZ_APP_SECRET=") || line == "# QOBUZ_APP_SECRET=" {
116            *line = format!("QOBUZ_APP_SECRET={app_secret}");
117            secret_written = true;
118        }
119    }
120
121    if !id_written {
122        updated_lines.push(format!("QOBUZ_APP_ID={app_id}"));
123    }
124    if !secret_written {
125        updated_lines.push(format!("QOBUZ_APP_SECRET={app_secret}"));
126    }
127
128    let content = updated_lines.join("\n");
129    write(path, content)?;
130
131    set_file_permissions(path)?;
132
133    Ok(())
134}
135
136/// Sets file permissions to owner-only read/write (`0600`) on Unix systems.
137///
138/// # Arguments
139///
140/// * `path` - Path to the file
141///
142/// # Returns
143///
144/// `Ok(())` on success.
145///
146/// # Errors
147///
148/// Returns `QobuzApiError` if permissions cannot be set.
149fn set_file_permissions(path: &Path) -> Result<(), QobuzApiError> {
150    #[cfg(unix)]
151    {
152        set_permissions(path, Permissions::from_mode(0o600))?;
153    }
154    Ok(())
155}
156
157/// Parses a single line of a `.env` file into a key-value pair.
158///
159/// Returns `None` for blank lines and comment lines (starting with `#`).
160fn parse_env_line(line: &str) -> Option<(String, String)> {
161    let trimmed = line.trim();
162    if trimmed.is_empty() || trimmed.starts_with('#') {
163        return None;
164    }
165
166    let (key, value) = trimmed.split_once('=')?;
167    let key = key.trim().to_string();
168    let value = value.trim();
169
170    let value = unquote(value).to_string();
171
172    Some((key, value))
173}
174
175/// Strips surrounding single or double quotes from a value string.
176fn unquote(s: &str) -> &str {
177    s.strip_prefix('"')
178        .and_then(|inner| inner.strip_suffix('"'))
179        .or_else(|| {
180            let inner = s.strip_prefix('\'')?;
181            inner.strip_suffix('\'')
182        })
183        .unwrap_or(s)
184}
185
186/// Parses a `.env` file and returns all key-value pairs.
187///
188/// Handles comments, blank lines, and optional quoting on values.
189///
190/// # Arguments
191///
192/// * `path` - Path to the `.env` file
193///
194/// # Returns
195///
196/// An `io::Result` with the parsed key-value pairs.
197///
198/// # Errors
199///
200/// Returns an I/O error if the file cannot be read.
201pub fn parse_env_file(path: &Path) -> IoResult<Vec<(String, String)>> {
202    let content = read_to_string(path)?;
203    Ok(content.lines().filter_map(parse_env_line).collect())
204}
205
206#[cfg(test)]
207mod tests {
208    use std::{fs::read_to_string, io::Write, path::Path};
209
210    use {
211        anyhow::{Result, anyhow, ensure},
212        tempfile::{NamedTempFile, tempdir},
213    };
214
215    use crate::credentials::{load_app_credentials, save_app_credentials};
216
217    /// Creates a temporary env file with the given contents.
218    ///
219    /// # Arguments
220    ///
221    /// * `contents` - File contents to write.
222    ///
223    /// # Errors
224    ///
225    /// Returns an error if the temp file cannot be created.
226    fn create_temp_env(contents: &str) -> Result<NamedTempFile> {
227        let mut file = NamedTempFile::new()?;
228        write!(file, "{contents}")?;
229        Ok(file)
230    }
231
232    /// Tests load app credentials reads existing file.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error if the test setup or assertion fails.
237    #[test]
238    fn load_app_credentials_reads_existing_file() -> Result<()> {
239        let file = create_temp_env("QOBUZ_APP_ID=12345\nQOBUZ_APP_SECRET=secret123\n")?;
240        let result = load_app_credentials(file.path())?;
241        let (id, secret) = result.ok_or_else(|| anyhow!("expected Some"))?;
242        ensure!(id == "12345", "id mismatch");
243        ensure!(secret == "secret123", "secret mismatch");
244        Ok(())
245    }
246
247    /// Tests load app credentials returns none for missing fields.
248    ///
249    /// # Errors
250    ///
251    /// Returns an error if the test setup or assertion fails.
252    #[test]
253    fn load_app_credentials_returns_none_for_missing_fields() -> Result<()> {
254        let file = create_temp_env("QOBUZ_APP_ID=12345\n")?;
255        let result = load_app_credentials(file.path())?;
256        ensure!(result.is_none(), "expected None when fields missing");
257        Ok(())
258    }
259
260    /// Tests load app credentials returns none for nonexistent file.
261    ///
262    /// # Errors
263    ///
264    /// Returns an error if the test setup or assertion fails.
265    #[test]
266    fn load_app_credentials_returns_none_for_nonexistent_file() -> Result<()> {
267        let result = load_app_credentials(Path::new("/nonexistent/.env"))?;
268        ensure!(result.is_none(), "expected None for nonexistent file");
269        Ok(())
270    }
271
272    /// Tests save app credentials creates new file.
273    ///
274    /// # Errors
275    ///
276    /// Returns an error if the test setup or assertion fails.
277    #[test]
278    fn save_app_credentials_creates_new_file() -> Result<()> {
279        let dir = tempdir()?;
280        let path = dir.path().join(".env");
281
282        save_app_credentials(&path, "app123", "secret456")?;
283
284        let content = read_to_string(&path)?;
285        ensure!(content.contains("QOBUZ_APP_ID=app123"), "missing app_id");
286        ensure!(
287            content.contains("QOBUZ_APP_SECRET=secret456"),
288            "missing app_secret"
289        );
290        Ok(())
291    }
292
293    /// Tests save app credentials updates existing values.
294    ///
295    /// # Errors
296    ///
297    /// Returns an error if the test setup or assertion fails.
298    #[test]
299    fn save_app_credentials_updates_existing_values() -> Result<()> {
300        let file = create_temp_env("QOBUZ_APP_ID=old\nQOBUZ_APP_SECRET=oldsecret\n")?;
301        save_app_credentials(file.path(), "new", "newsecret")?;
302
303        let content = read_to_string(file.path())?;
304        ensure!(
305            content.contains("QOBUZ_APP_ID=new"),
306            "should contain new id"
307        );
308        ensure!(
309            content.contains("QOBUZ_APP_SECRET=newsecret"),
310            "should contain new secret"
311        );
312        ensure!(!content.contains("old"), "should not contain old values");
313        Ok(())
314    }
315
316    /// Tests save app credentials replaces commented placeholders.
317    ///
318    /// # Errors
319    ///
320    /// Returns an error if the test setup or assertion fails.
321    #[test]
322    fn save_app_credentials_replaces_commented_placeholders() -> Result<()> {
323        let file = create_temp_env("# QOBUZ_APP_ID=\n# QOBUZ_APP_SECRET=\nQOBUZ_USER_ID=123\n")?;
324        save_app_credentials(file.path(), "app_id_val", "secret_val")?;
325
326        let content = read_to_string(file.path())?;
327        ensure!(
328            content.contains("QOBUZ_APP_ID=app_id_val"),
329            "should contain app_id"
330        );
331        ensure!(
332            content.contains("QOBUZ_APP_SECRET=secret_val"),
333            "should contain app_secret"
334        );
335        ensure!(
336            !content.contains("# QOBUZ_APP_ID="),
337            "should not contain commented placeholder"
338        );
339        ensure!(
340            !content.contains("# QOBUZ_APP_SECRET="),
341            "should not contain commented placeholder"
342        );
343        ensure!(
344            content.contains("QOBUZ_USER_ID=123"),
345            "should preserve other lines"
346        );
347        Ok(())
348    }
349}