car_auth/
authority_hint.rs1use serde::{Deserialize, Serialize};
2use std::io;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicBool, Ordering};
5
6const AUTHORITY_HINT_FILE: &str = "parslee-auth-authority.json";
7static FORCE_UNKNOWN: AtomicBool = AtomicBool::new(false);
8
9#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
11#[serde(rename_all = "snake_case")]
12pub enum CredentialAuthorityState {
13 Unknown,
14 SignedOut,
15 Configured,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(deny_unknown_fields)]
22pub struct CredentialAuthorityHint {
23 pub state: CredentialAuthorityState,
24 pub generation: u64,
25 pub updated_at_unix_ms: u64,
26}
27
28impl CredentialAuthorityHint {
29 pub fn unknown() -> Self {
30 Self {
31 state: CredentialAuthorityState::Unknown,
32 generation: 0,
33 updated_at_unix_ms: 0,
34 }
35 }
36
37 pub fn signed_out(generation: u64, updated_at_unix_ms: u64) -> Self {
38 Self {
39 state: CredentialAuthorityState::SignedOut,
40 generation,
41 updated_at_unix_ms,
42 }
43 }
44
45 pub fn configured(generation: u64, updated_at_unix_ms: u64) -> Self {
46 Self {
47 state: CredentialAuthorityState::Configured,
48 generation,
49 updated_at_unix_ms,
50 }
51 }
52}
53
54pub(crate) struct AuthorityHintStore {
55 root: PathBuf,
56}
57
58impl AuthorityHintStore {
59 pub(crate) fn new(root: PathBuf) -> Self {
60 Self { root }
61 }
62
63 pub(crate) fn path(&self) -> PathBuf {
64 self.root.join(AUTHORITY_HINT_FILE)
65 }
66
67 pub(crate) fn load(&self) -> CredentialAuthorityHint {
68 let path = self.path();
69 let metadata = match std::fs::symlink_metadata(&path) {
70 Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => metadata,
71 _ => return CredentialAuthorityHint::unknown(),
72 };
73 if metadata.len() > 16 * 1024 {
74 return CredentialAuthorityHint::unknown();
75 }
76 std::fs::read(&path)
77 .ok()
78 .and_then(|raw| serde_json::from_slice(&raw).ok())
79 .unwrap_or_else(CredentialAuthorityHint::unknown)
80 }
81
82 pub(crate) fn publish(&self, hint: CredentialAuthorityHint) -> io::Result<()> {
83 use std::io::Write;
84
85 std::fs::create_dir_all(&self.root)?;
86 let temp_path = self.root.join(format!(
87 ".{AUTHORITY_HINT_FILE}.{}.tmp",
88 uuid::Uuid::new_v4().simple()
89 ));
90 let result = (|| {
91 let mut file = open_private_temp(&temp_path)?;
92 let body = serde_json::to_vec(&hint)
93 .map_err(|error| io::Error::other(format!("serialize authority hint: {error}")))?;
94 file.write_all(&body)?;
95 file.sync_all()?;
96 atomic_replace(&temp_path, &self.path())?;
97 sync_directory(&self.root)?;
98 Ok(())
99 })();
100 if result.is_err() {
101 let _ = std::fs::remove_file(&temp_path);
102 }
103 result
104 }
105
106 pub(crate) fn discard(&self) -> io::Result<()> {
107 match std::fs::remove_file(self.path()) {
108 Ok(()) => sync_directory(&self.root),
109 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
110 Err(error) => Err(error),
111 }
112 }
113}
114
115#[cfg(unix)]
116fn open_private_temp(path: &Path) -> io::Result<std::fs::File> {
117 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
118
119 let file = std::fs::OpenOptions::new()
120 .write(true)
121 .create_new(true)
122 .mode(0o600)
123 .open(path)?;
124 file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
128 Ok(file)
129}
130
131#[cfg(not(unix))]
132fn open_private_temp(path: &Path) -> io::Result<std::fs::File> {
133 std::fs::OpenOptions::new()
134 .write(true)
135 .create_new(true)
136 .open(path)
137}
138
139#[cfg(not(windows))]
140fn atomic_replace(source: &Path, destination: &Path) -> io::Result<()> {
141 std::fs::rename(source, destination)
142}
143
144#[cfg(windows)]
145fn atomic_replace(source: &Path, destination: &Path) -> io::Result<()> {
146 use std::os::windows::ffi::OsStrExt;
147
148 const MOVEFILE_REPLACE_EXISTING: u32 = 0x1;
149 const MOVEFILE_WRITE_THROUGH: u32 = 0x8;
150 #[link(name = "kernel32")]
151 unsafe extern "system" {
152 fn MoveFileExW(existing: *const u16, new: *const u16, flags: u32) -> i32;
153 }
154 let source: Vec<u16> = source.as_os_str().encode_wide().chain(Some(0)).collect();
155 let destination: Vec<u16> = destination
156 .as_os_str()
157 .encode_wide()
158 .chain(Some(0))
159 .collect();
160 let replaced = unsafe {
163 MoveFileExW(
164 source.as_ptr(),
165 destination.as_ptr(),
166 MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
167 )
168 };
169 if replaced == 0 {
170 Err(io::Error::last_os_error())
171 } else {
172 Ok(())
173 }
174}
175
176#[cfg(unix)]
177fn sync_directory(path: &Path) -> io::Result<()> {
178 std::fs::File::open(path)?.sync_all()
179}
180
181#[cfg(not(unix))]
182fn sync_directory(_path: &Path) -> io::Result<()> {
183 Ok(())
184}
185
186pub fn credential_authority_hint() -> CredentialAuthorityHint {
188 if FORCE_UNKNOWN.load(Ordering::Acquire) {
189 return CredentialAuthorityHint::unknown();
190 }
191 let Some(root) = car_home::root() else {
192 return CredentialAuthorityHint::unknown();
193 };
194 AuthorityHintStore::new(root).load()
195}
196
197pub(crate) fn publish_for_state(state: &crate::state::AuthStateV2) -> io::Result<()> {
198 let root = car_home::root().ok_or_else(|| {
199 FORCE_UNKNOWN.store(true, Ordering::Release);
200 io::Error::new(io::ErrorKind::NotFound, "CAR state root is unavailable")
201 })?;
202 let now = crate::epoch_millis();
203 let hint = if state.active.is_some() {
204 CredentialAuthorityHint::configured(state.generation, now)
205 } else {
206 CredentialAuthorityHint::signed_out(state.generation, now)
207 };
208 match AuthorityHintStore::new(root).publish(hint) {
209 Ok(()) => {
210 FORCE_UNKNOWN.store(false, Ordering::Release);
211 Ok(())
212 }
213 Err(error) => {
214 FORCE_UNKNOWN.store(true, Ordering::Release);
215 Err(error)
216 }
217 }
218}
219
220pub(crate) fn degrade_to_unknown() -> io::Result<()> {
221 let root = car_home::root().ok_or_else(|| {
222 FORCE_UNKNOWN.store(true, Ordering::Release);
223 io::Error::new(io::ErrorKind::NotFound, "CAR state root is unavailable")
224 })?;
225 let store = AuthorityHintStore::new(root);
226 if let Err(error) = store.publish(CredentialAuthorityHint::unknown()) {
227 FORCE_UNKNOWN.store(true, Ordering::Release);
228 if store.discard().is_err() {
229 FORCE_UNKNOWN.store(true, Ordering::Release);
230 }
231 return Err(error);
232 }
233 Ok(())
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[cfg(unix)]
241 const FAILURE_CHILD_ENV: &str = "CAR_AUTHORITY_HINT_FAILURE_CHILD";
242
243 #[cfg(unix)]
244 fn run_failed_degradation_scenario() {
245 use std::os::unix::fs::PermissionsExt;
246
247 let dir = tempfile::tempdir().unwrap();
248 std::env::set_var(car_home::ENV_VAR, dir.path());
249 let store = AuthorityHintStore::new(dir.path().to_path_buf());
250 store
251 .publish(CredentialAuthorityHint::configured(7, 1234))
252 .unwrap();
253
254 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
255 let mut signed_out = crate::state::AuthStateV2::signed_out();
256 signed_out.generation = 8;
257 assert!(publish_for_state(&signed_out).is_err());
258 assert!(degrade_to_unknown().is_err());
259 let during_failure = credential_authority_hint();
260
261 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
262 signed_out.generation = 9;
263 publish_for_state(&signed_out).unwrap();
264 let after_recovery = credential_authority_hint();
265 std::env::remove_var(car_home::ENV_VAR);
266
267 assert_eq!(during_failure, CredentialAuthorityHint::unknown());
268 assert_eq!(after_recovery.state, CredentialAuthorityState::SignedOut);
269 assert_eq!(after_recovery.generation, 9);
270 }
271
272 #[cfg(unix)]
273 #[test]
274 fn failed_hint_degradation_forces_public_reads_unknown_until_success() {
275 if std::env::var_os(FAILURE_CHILD_ENV).is_some() {
276 run_failed_degradation_scenario();
277 return;
278 }
279
280 let output = std::process::Command::new(std::env::current_exe().unwrap())
281 .args([
282 "--exact",
283 "authority_hint::tests::failed_hint_degradation_forces_public_reads_unknown_until_success",
284 "--nocapture",
285 ])
286 .env(FAILURE_CHILD_ENV, "1")
287 .output()
288 .unwrap();
289 assert!(
290 output.status.success(),
291 "isolated authority-hint regression failed:\nstdout:\n{}\nstderr:\n{}",
292 String::from_utf8_lossy(&output.stdout),
293 String::from_utf8_lossy(&output.stderr)
294 );
295 }
296
297 #[test]
298 fn malformed_or_missing_hint_is_unknown_and_contains_no_identity() {
299 let dir = tempfile::tempdir().unwrap();
300 let store = AuthorityHintStore::new(dir.path().to_path_buf());
301 assert_eq!(store.load(), CredentialAuthorityHint::unknown());
302
303 std::fs::write(
304 store.path(),
305 br#"{"state":"configured","account_id":"forbidden"}"#,
306 )
307 .unwrap();
308 assert_eq!(store.load(), CredentialAuthorityHint::unknown());
309 }
310
311 #[test]
312 fn published_hint_is_atomic_private_and_token_free() {
313 let dir = tempfile::tempdir().unwrap();
314 let store = AuthorityHintStore::new(dir.path().to_path_buf());
315 store
316 .publish(CredentialAuthorityHint::configured(7, 1234))
317 .unwrap();
318
319 let body = std::fs::read_to_string(store.path()).unwrap();
320 assert!(!body.contains("token"));
321 assert!(!body.contains("account"));
322 assert_eq!(store.load().generation, 7);
323
324 store
325 .publish(CredentialAuthorityHint::signed_out(8, 2345))
326 .unwrap();
327 assert_eq!(store.load().generation, 8);
328 assert_eq!(store.load().state, CredentialAuthorityState::SignedOut);
329 #[cfg(unix)]
330 {
331 use std::os::unix::fs::PermissionsExt;
332 assert_eq!(
333 std::fs::metadata(store.path())
334 .unwrap()
335 .permissions()
336 .mode()
337 & 0o777,
338 0o600
339 );
340 }
341 assert!(
342 std::fs::read_dir(dir.path())
343 .unwrap()
344 .all(|entry| entry.unwrap().path() == store.path()),
345 "atomic publication must not leave staging files behind"
346 );
347 }
348}