Skip to main content

bbdown_core/
credentials.rs

1use crate::{Error, Result};
2use serde::{Deserialize, Serialize};
3use std::fmt;
4use std::fs::{self, OpenOptions};
5use std::io::Write;
6use std::path::{Path, PathBuf};
7
8#[derive(Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
9pub struct Credentials {
10    pub cookie: Option<String>,
11    pub access_key: Option<String>,
12    #[serde(default)]
13    pub tv_access_key: Option<String>,
14}
15
16impl fmt::Debug for Credentials {
17    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
18        let summary = self.redacted_summary();
19        formatter
20            .debug_struct("Credentials")
21            .field("has_cookie", &summary.has_cookie)
22            .field("has_access_key", &summary.has_access_key)
23            .field("has_tv_access_key", &summary.has_tv_access_key)
24            .finish()
25    }
26}
27
28impl Credentials {
29    #[must_use]
30    pub fn with_cookie(mut self, cookie: impl Into<String>) -> Self {
31        self.cookie = Some(cookie.into());
32        self
33    }
34
35    #[must_use]
36    pub fn with_access_key(mut self, access_key: impl Into<String>) -> Self {
37        self.access_key = Some(access_key.into());
38        self
39    }
40
41    #[must_use]
42    pub fn with_tv_access_key(mut self, tv_access_key: impl Into<String>) -> Self {
43        self.tv_access_key = Some(tv_access_key.into());
44        self
45    }
46
47    #[must_use]
48    pub fn is_empty(&self) -> bool {
49        self.cookie.as_deref().unwrap_or_default().is_empty()
50            && self.access_key.as_deref().unwrap_or_default().is_empty()
51            && self.tv_access_key.as_deref().unwrap_or_default().is_empty()
52    }
53
54    #[must_use]
55    pub fn redacted_summary(&self) -> CredentialSource {
56        CredentialSource {
57            has_cookie: self
58                .cookie
59                .as_deref()
60                .is_some_and(|value| !value.is_empty()),
61            has_access_key: self
62                .access_key
63                .as_deref()
64                .is_some_and(|value| !value.is_empty()),
65            has_tv_access_key: self
66                .tv_access_key
67                .as_deref()
68                .is_some_and(|value| !value.is_empty()),
69        }
70    }
71}
72
73#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
74pub struct CredentialSource {
75    pub has_cookie: bool,
76    pub has_access_key: bool,
77    pub has_tv_access_key: bool,
78}
79
80#[derive(Clone, Debug)]
81pub struct CredentialStore {
82    path: PathBuf,
83}
84
85impl CredentialStore {
86    #[must_use]
87    pub fn new(path: PathBuf) -> Self {
88        Self { path }
89    }
90
91    pub fn load(&self) -> Result<Credentials> {
92        if !self.path.exists() {
93            return Ok(Credentials::default());
94        }
95        let raw = fs::read_to_string(&self.path)?;
96        serde_json::from_str(&raw).map_err(Error::from)
97    }
98
99    pub fn save(&self, credentials: &Credentials) -> Result<()> {
100        if let Some(parent) = self
101            .path
102            .parent()
103            .filter(|parent| !parent.as_os_str().is_empty())
104        {
105            fs::create_dir_all(parent)?;
106        }
107        write_private_file(&self.path, &serde_json::to_vec_pretty(credentials)?)
108    }
109
110    pub fn clear(&self) -> Result<()> {
111        match fs::remove_file(&self.path) {
112            Ok(()) => Ok(()),
113            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
114            Err(error) => Err(Error::Io(error)),
115        }
116    }
117
118    #[must_use]
119    pub fn path(&self) -> &Path {
120        &self.path
121    }
122}
123
124#[cfg(unix)]
125fn write_private_file(path: &Path, bytes: &[u8]) -> Result<()> {
126    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
127    let tmp_path = private_temp_path(path);
128    match fs::remove_file(&tmp_path) {
129        Ok(()) => {}
130        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
131        Err(error) => return Err(Error::Io(error)),
132    }
133    let mut file = OpenOptions::new()
134        .create_new(true)
135        .write(true)
136        .mode(0o600)
137        .open(&tmp_path)?;
138    file.write_all(bytes)?;
139    file.sync_all()?;
140    drop(file);
141    fs::rename(&tmp_path, path).map_err(|error| {
142        let _ = fs::remove_file(&tmp_path);
143        Error::Io(error)
144    })?;
145    fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
146    Ok(())
147}
148
149#[cfg(unix)]
150fn private_temp_path(path: &Path) -> std::path::PathBuf {
151    let file_name = path
152        .file_name()
153        .and_then(|name| name.to_str())
154        .unwrap_or("credentials");
155    path.with_file_name(format!(".{file_name}.tmp-{}", std::process::id()))
156}
157
158#[cfg(not(unix))]
159fn write_private_file(path: &Path, bytes: &[u8]) -> Result<()> {
160    let mut file = OpenOptions::new()
161        .create(true)
162        .truncate(true)
163        .write(true)
164        .open(path)?;
165    file.write_all(bytes)?;
166    Ok(())
167}
168
169#[cfg(test)]
170mod tests {
171    use super::{CredentialStore, Credentials};
172
173    #[test]
174    fn stores_credentials_without_leaking_values_in_summary() -> anyhow::Result<()> {
175        let temp = tempfile::tempdir()?;
176        let store = CredentialStore::new(temp.path().join("credentials.json"));
177        store.save(&Credentials {
178            cookie: Some("SESSDATA=secret".to_owned()),
179            access_key: Some("token".to_owned()),
180            tv_access_key: Some("tv-token".to_owned()),
181        })?;
182
183        let loaded = store.load()?;
184        assert_eq!(loaded.cookie.as_deref(), Some("SESSDATA=secret"));
185        assert_eq!(loaded.tv_access_key.as_deref(), Some("tv-token"));
186        assert_eq!(
187            loaded.redacted_summary(),
188            super::CredentialSource {
189                has_cookie: true,
190                has_access_key: true,
191                has_tv_access_key: true,
192            }
193        );
194        Ok(())
195    }
196
197    #[test]
198    fn credentials_debug_is_redacted() {
199        let debug = format!(
200            "{:?}",
201            Credentials {
202                cookie: Some("SESSDATA=secret".to_owned()),
203                access_key: Some("access-token".to_owned()),
204                tv_access_key: Some("tv-access-token".to_owned()),
205            }
206        );
207
208        assert!(debug.contains("has_cookie: true"));
209        assert!(debug.contains("has_access_key: true"));
210        assert!(debug.contains("has_tv_access_key: true"));
211        assert!(!debug.contains("SESSDATA=secret"));
212        assert!(!debug.contains("access-token"));
213        assert!(!debug.contains("tv-access-token"));
214    }
215
216    #[cfg(unix)]
217    #[test]
218    fn save_tightens_existing_file_permissions() -> anyhow::Result<()> {
219        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
220
221        let temp = tempfile::tempdir()?;
222        let path = temp.path().join("credentials.json");
223        std::fs::OpenOptions::new()
224            .create(true)
225            .truncate(true)
226            .write(true)
227            .mode(0o644)
228            .open(&path)?;
229
230        let store = CredentialStore::new(path.clone());
231        store.save(&Credentials {
232            cookie: Some("SESSDATA=secret".to_owned()),
233            access_key: None,
234            tv_access_key: None,
235        })?;
236
237        let mode = std::fs::metadata(path)?.permissions().mode() & 0o777;
238        assert_eq!(mode, 0o600);
239        Ok(())
240    }
241
242    #[test]
243    fn save_allows_bare_relative_path() -> anyhow::Result<()> {
244        use std::sync::{Mutex, OnceLock};
245
246        static CWD_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
247        let lock = CWD_LOCK.get_or_init(|| Mutex::new(()));
248        let _guard = lock
249            .lock()
250            .map_err(|error| anyhow::anyhow!("cwd lock poisoned: {error}"))?;
251
252        let original = std::env::current_dir()?;
253        let temp = tempfile::tempdir()?;
254        std::env::set_current_dir(temp.path())?;
255
256        let save_result =
257            CredentialStore::new(std::path::PathBuf::from("credentials.json")).save(&Credentials {
258                cookie: Some("SESSDATA=secret".to_owned()),
259                access_key: None,
260                tv_access_key: None,
261            });
262        std::env::set_current_dir(original)?;
263        save_result?;
264
265        assert!(temp.path().join("credentials.json").exists());
266        Ok(())
267    }
268}