1use std::fs::{self, File, OpenOptions};
8use std::io::{Read, Write};
9use std::path::{Path, PathBuf};
10use std::thread;
11use std::time::{Duration, Instant};
12
13use atomic_write_file::AtomicWriteFile;
14use fs4::TryLockError;
15use fx_provider::{Credential, CredentialLease, CredentialStore, ProviderError};
16use serde::{Deserialize, Serialize};
17use zeroize::Zeroizing;
18
19const FORMAT_VERSION: u32 = 1;
20const MAX_CREDENTIAL_BYTES: u64 = 64 * 1024;
21const DEFAULT_LOCK_TIMEOUT: Duration = Duration::from_secs(5);
22
23#[derive(Clone, Debug)]
24pub struct FileCredentialStore {
25 root: PathBuf,
26 lock_timeout: Duration,
27}
28
29impl FileCredentialStore {
30 pub fn from_home(home: impl AsRef<Path>) -> Self {
31 Self::new(home.as_ref().join(".fx").join("credentials"))
32 }
33
34 pub fn new(root: impl Into<PathBuf>) -> Self {
35 Self {
36 root: root.into(),
37 lock_timeout: DEFAULT_LOCK_TIMEOUT,
38 }
39 }
40
41 pub fn with_lock_timeout(mut self, timeout: Duration) -> Self {
42 self.lock_timeout = timeout;
43 self
44 }
45
46 pub fn root(&self) -> &Path {
47 &self.root
48 }
49
50 fn acquire(&self, provider_id: &str) -> Result<FileCredentialLease<'_>, ProviderError> {
51 validate_provider_id(provider_id)?;
52 ensure_private_directory(&self.root)?;
53 let locks = self.root.join(".locks");
54 ensure_private_directory(&locks)?;
55 let lock_path = locks.join(format!("{provider_id}.lock"));
56 let lock = open_private_lock(&lock_path)?;
57 let deadline = Instant::now() + self.lock_timeout;
58 loop {
59 match fs4::FileExt::try_lock(&lock) {
60 Ok(()) => break,
61 Err(TryLockError::WouldBlock) if Instant::now() < deadline => {
62 thread::sleep(Duration::from_millis(10));
63 }
64 Err(TryLockError::WouldBlock) => {
65 return Err(ProviderError::CredentialStore(format!(
66 "timed out locking credential for `{provider_id}`"
67 )));
68 }
69 Err(TryLockError::Error(error)) => {
70 return Err(store_io("locking credential", error));
71 }
72 }
73 }
74 let path = self.root.join(format!("{provider_id}.json"));
75 let credential = read_credential(&path)?;
76 Ok(FileCredentialLease {
77 _store: self,
78 _lock: lock,
79 path,
80 credential,
81 })
82 }
83}
84
85impl CredentialStore for FileCredentialStore {
86 fn lock<'a>(
87 &'a self,
88 provider_id: &str,
89 ) -> Result<Box<dyn CredentialLease + 'a>, ProviderError> {
90 Ok(Box::new(self.acquire(provider_id)?))
91 }
92}
93
94struct FileCredentialLease<'a> {
95 _store: &'a FileCredentialStore,
96 _lock: File,
97 path: PathBuf,
98 credential: Option<Credential>,
99}
100
101impl CredentialLease for FileCredentialLease<'_> {
102 fn credential(&self) -> Option<&Credential> {
103 self.credential.as_ref()
104 }
105
106 fn replace(&mut self, credential: Credential) -> Result<(), ProviderError> {
107 write_credential(&self.path, &credential)?;
108 self.credential = Some(credential);
109 Ok(())
110 }
111
112 fn delete(&mut self) -> Result<(), ProviderError> {
113 match fs::symlink_metadata(&self.path) {
114 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
115 return Err(ProviderError::CredentialStore(format!(
116 "credential path `{}` is not a regular file",
117 self.path.display()
118 )));
119 }
120 Ok(_) => fs::remove_file(&self.path)
121 .map_err(|error| store_io("deleting credential", error))?,
122 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
123 Err(error) => return Err(store_io("inspecting credential", error)),
124 }
125 self.credential = None;
126 Ok(())
127 }
128}
129
130#[derive(Deserialize, Serialize)]
131#[serde(deny_unknown_fields)]
132struct StoredCredential {
133 version: u32,
134 credential: Credential,
135}
136
137fn read_credential(path: &Path) -> Result<Option<Credential>, ProviderError> {
138 let file = match open_read_no_follow(path) {
139 Ok(file) => file,
140 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
141 Err(error) => return Err(store_io("opening credential", error)),
142 };
143 let metadata = file
144 .metadata()
145 .map_err(|error| store_io("inspecting credential", error))?;
146 if !metadata.is_file() || !private_file_permissions(&metadata) {
147 return Err(ProviderError::CredentialStore(format!(
148 "credential `{}` is not a private regular file",
149 path.display()
150 )));
151 }
152 let mut bytes = Zeroizing::new(Vec::new());
153 file.take(MAX_CREDENTIAL_BYTES + 1)
154 .read_to_end(&mut bytes)
155 .map_err(|error| store_io("reading credential", error))?;
156 if bytes.len() as u64 > MAX_CREDENTIAL_BYTES {
157 return Err(ProviderError::CredentialStore(format!(
158 "credential `{}` exceeds 64 KiB",
159 path.display()
160 )));
161 }
162 let stored: StoredCredential = serde_json::from_slice(&bytes).map_err(|_| {
163 ProviderError::CredentialStore(format!(
164 "credential `{}` is corrupt or has an unsupported schema",
165 path.display()
166 ))
167 })?;
168 if stored.version != FORMAT_VERSION {
169 return Err(ProviderError::CredentialStore(format!(
170 "credential `{}` uses unsupported version {}",
171 path.display(),
172 stored.version
173 )));
174 }
175 Ok(Some(stored.credential))
176}
177
178fn write_credential(path: &Path, credential: &Credential) -> Result<(), ProviderError> {
179 if fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
180 return Err(ProviderError::CredentialStore(format!(
181 "credential `{}` is a symbolic link",
182 path.display()
183 )));
184 }
185 let stored = StoredCredential {
186 version: FORMAT_VERSION,
187 credential: credential.clone(),
188 };
189 let mut bytes = Zeroizing::new(
190 serde_json::to_vec(&stored)
191 .map_err(|error| ProviderError::CredentialStore(error.to_string()))?,
192 );
193 bytes.push(b'\n');
194 if bytes.len() as u64 > MAX_CREDENTIAL_BYTES {
195 return Err(ProviderError::CredentialStore(
196 "serialized credential exceeds 64 KiB".into(),
197 ));
198 }
199 let mut stage =
200 AtomicWriteFile::open(path).map_err(|error| store_io("staging credential", error))?;
201 set_private_file(stage.as_file())?;
202 stage
203 .write_all(&bytes)
204 .map_err(|error| store_io("writing credential", error))?;
205 stage
206 .commit()
207 .map_err(|error| store_io("committing credential", error))
208}
209
210fn validate_provider_id(value: &str) -> Result<(), ProviderError> {
211 let valid = !value.is_empty()
212 && value.len() <= 128
213 && value
214 .bytes()
215 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'));
216 if valid {
217 Ok(())
218 } else {
219 Err(ProviderError::CredentialStore(format!(
220 "unsafe provider id `{value}`"
221 )))
222 }
223}
224
225fn ensure_private_directory(path: &Path) -> Result<(), ProviderError> {
226 match fs::symlink_metadata(path) {
227 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
228 return Err(ProviderError::CredentialStore(format!(
229 "credential directory `{}` is not a directory",
230 path.display()
231 )));
232 }
233 Ok(_) => {}
234 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
235 fs::create_dir_all(path)
236 .map_err(|error| store_io("creating credential directory", error))?;
237 }
238 Err(error) => return Err(store_io("inspecting credential directory", error)),
239 }
240 #[cfg(unix)]
241 {
242 use std::os::unix::fs::PermissionsExt;
243 fs::set_permissions(path, fs::Permissions::from_mode(0o700))
244 .map_err(|error| store_io("securing credential directory", error))?;
245 }
246 Ok(())
247}
248
249fn open_private_lock(path: &Path) -> Result<File, ProviderError> {
250 if fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
251 return Err(ProviderError::CredentialStore(format!(
252 "credential lock `{}` is a symbolic link",
253 path.display()
254 )));
255 }
256 let mut options = OpenOptions::new();
257 options.create(true).read(true).write(true);
258 #[cfg(unix)]
259 {
260 use std::os::unix::fs::OpenOptionsExt;
261 options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
262 }
263 let file = options
264 .open(path)
265 .map_err(|error| store_io("opening credential lock", error))?;
266 set_private_file(&file)?;
267 Ok(file)
268}
269
270fn open_read_no_follow(path: &Path) -> std::io::Result<File> {
271 let mut options = OpenOptions::new();
272 options.read(true);
273 #[cfg(unix)]
274 {
275 use std::os::unix::fs::OpenOptionsExt;
276 options.custom_flags(libc::O_NOFOLLOW);
277 }
278 options.open(path)
279}
280
281fn set_private_file(file: &File) -> Result<(), ProviderError> {
282 #[cfg(unix)]
283 {
284 use std::os::unix::fs::PermissionsExt;
285 file.set_permissions(fs::Permissions::from_mode(0o600))
286 .map_err(|error| store_io("securing credential file", error))?;
287 }
288 Ok(())
289}
290
291fn private_file_permissions(metadata: &fs::Metadata) -> bool {
292 #[cfg(unix)]
293 {
294 use std::os::unix::fs::PermissionsExt;
295 metadata.permissions().mode() & 0o077 == 0
296 }
297 #[cfg(not(unix))]
298 {
299 let _ = metadata;
300 true
301 }
302}
303
304fn store_io(operation: &str, error: std::io::Error) -> ProviderError {
305 ProviderError::CredentialStore(format!("{operation}: {error}"))
306}
307
308#[cfg(test)]
309mod tests {
310 use std::collections::BTreeMap;
311
312 use super::*;
313
314 fn temporary(name: &str) -> PathBuf {
315 std::env::temp_dir().join(format!(
316 "fx-auth-{name}-{}-{}",
317 std::process::id(),
318 std::time::SystemTime::now()
319 .duration_since(std::time::UNIX_EPOCH)
320 .unwrap()
321 .as_nanos()
322 ))
323 }
324
325 fn key(value: &str) -> Credential {
326 Credential::ApiKey {
327 secret: value.into(),
328 attributes: BTreeMap::new(),
329 }
330 }
331
332 #[test]
333 fn isolates_provider_credentials_and_deletes_only_one() {
334 let root = temporary("isolation");
335 let store = FileCredentialStore::new(&root);
336 store.lock("alpha").unwrap().replace(key("a")).unwrap();
337 store.lock("beta").unwrap().replace(key("b")).unwrap();
338 assert!(store.lock("alpha").unwrap().credential().is_some());
339 store.lock("alpha").unwrap().delete().unwrap();
340 assert!(store.lock("alpha").unwrap().credential().is_none());
341 assert!(store.lock("beta").unwrap().credential().is_some());
342 fs::remove_dir_all(root).unwrap();
343 }
344
345 #[test]
346 fn corrupt_credentials_fail_closed() {
347 let root = temporary("corrupt");
348 fs::create_dir_all(&root).unwrap();
349 let path = root.join("alpha.json");
350 fs::write(&path, b"not-json").unwrap();
351 #[cfg(unix)]
352 {
353 use std::os::unix::fs::PermissionsExt;
354 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
355 }
356 let error = FileCredentialStore::new(&root).lock("alpha").err().unwrap();
357 assert!(error.to_string().contains("corrupt"));
358 fs::remove_dir_all(root).unwrap();
359 }
360
361 #[cfg(unix)]
362 #[test]
363 fn stored_files_and_directories_are_private() {
364 use std::os::unix::fs::PermissionsExt;
365
366 let root = temporary("mode");
367 let store = FileCredentialStore::new(&root);
368 store.lock("alpha").unwrap().replace(key("a")).unwrap();
369 assert_eq!(
370 fs::metadata(&root).unwrap().permissions().mode() & 0o777,
371 0o700
372 );
373 assert_eq!(
374 fs::metadata(root.join("alpha.json"))
375 .unwrap()
376 .permissions()
377 .mode()
378 & 0o777,
379 0o600
380 );
381 fs::remove_dir_all(root).unwrap();
382 }
383}