bbdown_core/
credentials.rs1use 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}