oauth2_broker/store/
file.rs

1//! Simple file-backed [`BrokerStore`] for lightweight deployments and bots.
2
3// std
4use std::{
5	fs::{self, File},
6	io::Write,
7	path::{Path, PathBuf},
8};
9// self
10use crate::{
11	_prelude::*,
12	auth::{ScopeSet, TokenFamily, TokenRecord, TokenSecret},
13	store::{BrokerStore, CompareAndSwapOutcome, StoreError, StoreFuture, StoreKey},
14};
15
16/// Persists broker records to a JSON file after each mutation.
17#[derive(Clone, Debug)]
18pub struct FileStore {
19	path: PathBuf,
20	inner: Arc<RwLock<HashMap<StoreKey, TokenRecord>>>,
21}
22impl FileStore {
23	/// Opens (or creates) a store at the provided path, eagerly loading existing data.
24	pub fn open(path: impl Into<PathBuf>) -> Result<Self, StoreError> {
25		let path = path.into();
26
27		Self::ensure_parent_exists(&path)?;
28
29		let snapshot = if path.exists() { Self::load_snapshot(&path)? } else { HashMap::new() };
30
31		Ok(Self { path, inner: Arc::new(RwLock::new(snapshot)) })
32	}
33
34	fn load_snapshot(path: &Path) -> Result<HashMap<StoreKey, TokenRecord>, StoreError> {
35		if !path.exists() {
36			return Ok(HashMap::new());
37		}
38
39		let metadata = path.metadata().map_err(|e| StoreError::Backend {
40			message: format!("Failed to inspect {}: {e}", path.display()),
41		})?;
42
43		if metadata.len() == 0 {
44			return Ok(HashMap::new());
45		}
46
47		let bytes = fs::read(path).map_err(|e| StoreError::Backend {
48			message: format!("Failed to read {}: {e}", path.display()),
49		})?;
50
51		let entries: Vec<(StoreKey, TokenRecord)> =
52			serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization {
53				message: format!("Failed to parse {}: {e}", path.display()),
54			})?;
55
56		Ok(entries.into_iter().collect())
57	}
58
59	fn ensure_parent_exists(path: &Path) -> Result<(), StoreError> {
60		if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
61			fs::create_dir_all(parent).map_err(|e| StoreError::Backend {
62				message: format!("Failed to create store directory {}: {e}", parent.display()),
63			})?;
64		}
65		Ok(())
66	}
67
68	fn persist_locked(&self, contents: &HashMap<StoreKey, TokenRecord>) -> Result<(), StoreError> {
69		Self::ensure_parent_exists(&self.path)?;
70
71		let snapshot: Vec<_> = contents.iter().collect();
72		let serialized =
73			serde_json::to_vec_pretty(&snapshot).map_err(|e| StoreError::Serialization {
74				message: format!("Failed to serialize store snapshot: {e}"),
75			})?;
76		let mut tmp_path = self.path.clone();
77
78		tmp_path.set_extension("tmp");
79
80		{
81			let mut file = File::create(&tmp_path).map_err(|e| StoreError::Backend {
82				message: format!("Failed to create {}: {e}", tmp_path.display()),
83			})?;
84
85			file.write_all(&serialized).map_err(|e| StoreError::Backend {
86				message: format!("Failed to write {}: {e}", tmp_path.display()),
87			})?;
88			file.sync_all().map_err(|e| StoreError::Backend {
89				message: format!("Failed to sync {}: {e}", tmp_path.display()),
90			})?;
91		}
92
93		fs::rename(&tmp_path, &self.path).map_err(|e| StoreError::Backend {
94			message: format!("Failed to replace {}: {e}", self.path.display()),
95		})
96	}
97
98	fn make_key(family: &TokenFamily, scope: &ScopeSet) -> StoreKey {
99		StoreKey::new(family, scope)
100	}
101
102	fn refresh_matches(current: Option<&TokenSecret>, expected: Option<&str>) -> bool {
103		match (current.map(TokenSecret::expose), expected) {
104			(None, None) => true,
105			(Some(cur), Some(exp)) => cur == exp,
106			_ => false,
107		}
108	}
109}
110impl BrokerStore for FileStore {
111	fn save(&self, record: TokenRecord) -> StoreFuture<'_, ()> {
112		Box::pin(async move {
113			let key = Self::make_key(&record.family, &record.scope);
114			let mut guard = self.inner.write();
115
116			guard.insert(key, record);
117			self.persist_locked(&guard)?;
118
119			Ok(())
120		})
121	}
122
123	fn fetch<'a>(
124		&'a self,
125		family: &'a TokenFamily,
126		scope: &'a ScopeSet,
127	) -> StoreFuture<'a, Option<TokenRecord>> {
128		Box::pin(async move {
129			let key = Self::make_key(family, scope);
130
131			Ok(self.inner.read().get(&key).cloned())
132		})
133	}
134
135	fn compare_and_swap_refresh<'a>(
136		&'a self,
137		family: &'a TokenFamily,
138		scope: &'a ScopeSet,
139		expected_refresh: Option<&'a str>,
140		replacement: TokenRecord,
141	) -> StoreFuture<'a, CompareAndSwapOutcome> {
142		Box::pin(async move {
143			let key = Self::make_key(family, scope);
144			let mut guard = self.inner.write();
145			let outcome = match guard.get(&key) {
146				Some(existing)
147					if Self::refresh_matches(existing.refresh_token.as_ref(), expected_refresh) =>
148					CompareAndSwapOutcome::Updated,
149				Some(_) => CompareAndSwapOutcome::RefreshMismatch,
150				None => CompareAndSwapOutcome::Missing,
151			};
152
153			if matches!(outcome, CompareAndSwapOutcome::Updated) {
154				guard.insert(key, replacement);
155				self.persist_locked(&guard)?;
156			}
157
158			Ok(outcome)
159		})
160	}
161
162	fn revoke<'a>(
163		&'a self,
164		family: &'a TokenFamily,
165		scope: &'a ScopeSet,
166		instant: OffsetDateTime,
167	) -> StoreFuture<'a, Option<TokenRecord>> {
168		Box::pin(async move {
169			let key = Self::make_key(family, scope);
170			let mut guard = self.inner.write();
171			let result = match guard.get_mut(&key) {
172				Some(record) => {
173					record.revoke(instant);
174
175					let cloned = record.clone();
176
177					self.persist_locked(&guard)?;
178
179					Some(cloned)
180				},
181				None => None,
182			};
183
184			Ok(result)
185		})
186	}
187}
188
189#[cfg(test)]
190mod tests {
191	// std
192	use std::{env, process};
193	// crates.io
194	use tokio::runtime::Runtime;
195	// self
196	use super::*;
197	use crate::auth::{PrincipalId, TenantId};
198
199	fn temp_path() -> PathBuf {
200		let unique = format!(
201			"oauth2_broker_file_store_{}_{}.json",
202			process::id(),
203			OffsetDateTime::now_utc().unix_timestamp_nanos(),
204		);
205
206		env::temp_dir().join(unique)
207	}
208
209	fn build_record() -> (TokenFamily, ScopeSet, TokenRecord) {
210		let tenant = TenantId::new("tenant-demo").expect("Failed to build tenant fixture.");
211		let principal =
212			PrincipalId::new("principal-demo").expect("Failed to build principal fixture.");
213		let scope = ScopeSet::new(["tweet.read"]).expect("Failed to build scope fixture.");
214		let family = TokenFamily::new(tenant, principal);
215		let record = TokenRecord::builder(family.clone(), scope.clone())
216			.access_token("access-token")
217			.expires_in(Duration::hours(1))
218			.build()
219			.expect("Failed to build file-store test record.");
220
221		(family, scope, record)
222	}
223
224	#[test]
225	fn save_and_reload_round_trip() {
226		let path = temp_path();
227		let store = FileStore::open(&path).expect("Failed to open file store snapshot.");
228		let (family, scope, record) = build_record();
229		let rt = Runtime::new().expect("Failed to build Tokio runtime for file store test.");
230
231		rt.block_on(store.save(record.clone()))
232			.expect("Failed to save fixture record to file store.");
233		drop(store);
234
235		let reopened = FileStore::open(&path).expect("Failed to reopen file store snapshot.");
236		let fetched = rt
237			.block_on(reopened.fetch(&family, &scope))
238			.expect("Failed to fetch fixture record from file store.")
239			.expect("File store lost record after reopen.");
240
241		assert_eq!(fetched.access_token.expose(), record.access_token.expose());
242
243		fs::remove_file(&path).unwrap_or_else(|e| {
244			panic!("Failed to remove temporary file store snapshot {}: {e}", path.display())
245		});
246	}
247}